camdl Run System Specification
Version: 0.5-draft
Date: 2026-10-24
Scope: This spec describes camdl’s run system: forward simulation (single runs, multi-cell ensembles and
--sweepgrids), inference pipelines (fit.toml), parameter sweeps, predictive workflows, and the content-addressed store that holds every result together with its provenance.
1. Design Principles
1.1 Background: How camdl Partitions Model Inputs
A camdl model defines a stochastic simulator whose inputs are partitioned into three categories:
Model parameters (M): the tuneable knobs — quantities that could be varied during calibration or sensitivity analysis. Transmission rate β, recovery rate γ, reporting probability ρ. Declared in the
.camdlfile’sparameters { }block.Configuration (C): structural and runtime choices that are never subject to calibration. Population structure, integrator and step size, the output schedule, which interventions are enabled. Defined by the
.camdlmodel plus the CLI/config knobs that select a backend and adt.Seed (s ∈ S): the base random seed for stochastic simulation. Always a CLI or config-file argument, never baked into the model.
A simulation is the mapping Sim(m, c, s) → y, producing a trajectory and (optionally) sampled observations. Every workflow here — forward simulation, sweeps, inference, predictive checks — is an operation on these three layers.
Scenarios (σ) are deterministic patches that modify parameters and/or configuration from their baseline values: σ(m, c) → (m′, c′). They are declared in the .camdl file’s scenarios { } block (compiled to model.presets in the IR) and selected at runtime with --scenario, or constructed ad hoc on the CLI with --enable / --disable. A scenario that patches nothing is the baseline.
Inference operates on a view of the parameter space. When fitting, some parameters are estimated and others are held fixed. That partition — [estimate] versus [fixed] in fit.toml — defines which parameters the algorithm explores and which it treats as known constants.
1.2 File Roles and Separation of Concerns
One file per concern, no overlap.
model.camdl → what the model IS (structure, priors, scenarios)
params.toml → a point m ∈ M (concrete parameter values)
fit.toml → how inference RUNS (what to estimate, priors, data, one method)
The .camdl file owns structure. It also owns two things that are easy to mislocate: parameter priors (p ~ Normal(0, 1) in the parameters block) and named scenario presets. It does not own concrete parameter point values outside a preset.
| Concern | Declared in | Overridable by |
|---|---|---|
| Model structure | .camdl only |
never |
| Parameter names/types | .camdl only |
never |
| Parameter values (M) | params TOML, [fixed], a scenario’s set |
draw/sweep point, scenario, --param/--fixed |
| Scenarios (σ) | .camdl scenarios { } |
CLI --enable/--disable |
| Interventions | .camdl only |
scenarios and --enable/--disable toggle them |
| Priors | .camdl ~, fit.toml [estimate.p.prior] |
fit.toml wins over the model |
Backend / dt |
CLI, fit.toml [method] |
--backend / --dt on the simulate-side commands |
| Seeds | CLI, fit_seeds |
--seed / --seeds |
| Sweep / design | CLI --sweep / --sweep-file |
never |
| Estimate vs fixed | fit.toml only |
fit run --sweep varies a [fixed] parameter |
| Which method runs | fit.toml [method] |
never — a second method is a second file |
The model file is self-contained for a single run provided every parameter gets a value from somewhere: a params TOML, a scenario, or a --param. A parameters {} declaration never carries a value (names, kinds, bounds and priors only), so a parameter with no value from any source is a hard error naming the parameter and the sources that were consulted.
1.3 Precedence Chains
Parameter resolution for the simulate-side commands (simulate, pfilter, survey, profile, eval) is a single function, params_resolver::resolve_parameters (fit run calls it too), which is the only writer of model.parameters[i].value outside the IR layer. It layers six tiers, last wins:
1. compiled-in value (`p.value` already in the IR; see below)
2. fit.toml [fixed] (inference commands, and --fit on others)
3. --params / --fixed-file (each file layered in order)
3.5 draw row or sweep point (automated M-layer variation)
4. scenario (preset `params` + multiplicative `scale`,
or an inline ad-hoc `set`/`scale`)
5. --param / --fixed (the user's explicit assertion; highest: overrides
every value a selected scenario does not set or
scale; a collision with a scenario is refused)
Tier 1 is whatever value the compiled IR already holds for a parameter. A .camdl never writes one (parameters {} declares names, kinds, bounds and priors; beta : rate in [0.001, 2.0] = 0.3 is a syntax error), and the compiler emits every declared parameter as estimated (bounds or a prior, no start) or required (neither), both without a number. Tier 1 is therefore empty for any model compiled from a .camdl, except where camdlc --set NAME=V pinned the parameter to a fixed constant at compile time (or the IR was written by hand). When it does supply a value, the provenance label is model_default.
A generated draw row (--draws prior|uniform) carries only the parameters its measure varies — the parameters with a prior, or the bounded parameters a uniform sweep draws. Every other parameter resolves per cell through tiers 1–5, so --params and each cell’s own scenario reach it (gh#949).
The structural distinction between 3.5 and 5: a draw or sweep value is automated M-layer variation and is counterfactual-modifiable, so a scenario patch (tier 4) overrides it. A --param/--fixed value is the user’s explicit assertion about this specific run and overrides every value a selected scenario does not set or scale; a collision with a scenario is refused (below). A named preset and an equivalent inline ad-hoc scenario resolve at the same tier, so they are indistinguishable in the resolved value — only the recorded provenance label differs.
No replacement is silent. Whenever a tier replaces, with a different value, a value that a tier other than tier 1 supplied, the run says so on stderr — one line per replacement, naming the parameter, both values and both sources — and records it in each leaf’s run.json (inputs.parameters_provenance.<name>.overrides, beside the lines themselves in inputs.notices; recorded, never hashed):
info: scenario `lockdown` replaced I0 = 5 (from p.toml) with 3
warning: scenario `lockdown` replaced I0 = 5 (from mle_params.toml, a fit's estimate) with 3
A fit config’s [synthetic] true_params file is tier 5 for the forward run that draws the synthetic dataset, so it replaces a value the [synthetic] scenario sets. That notice names the file, not a flag:
info: [synthetic] true_params truth.toml replaced beta = 0.8 (from scenario `hi`) with 0.3
A replacement is a warning when the replaced value is a fit’s estimate (it came from a params file carrying a fit [provenance] block): the run then simulates something other than what the fit estimated. Every other replacement is info — it is one the command’s own flags asked for (a later --params file, a scenario, a sweep or draw, --param). Replacing a tier-1 value, and replacing a value with an equal one, are not reported. A multi-cell run reports each (parameter, replaced source, winning source) once, with the range of values when they differ by cell.
Precedence orders values; it does not grant permission. Where a scenario would discard a value the user wrote for the same parameter, or a value the user wrote would discard a scenario’s, the run is refused before anything is written, rather than resolved:
a
--param NAME=VALUEor a--param-vec PREFIX=FILErow (simulate), or a--fixed NAME=VALUE(survey,profile), on a parameter a selected scenariosets orscales, directly or through a preset it composes (gh#571). Tier 5 would replace the scenario’s value in every cell, so differently labelled scenarios would all run the flag’s value. The error names the flag (for--param-vec, the file and the row), every colliding scenario and what it does, and where the value belongs instead: in the model’sscenarios {}block (asetin the scenario that needs it, or a variant thatcomposes the colliding scenario), in a--params/--fixed-filefile for ascale(a scale multiplies the file’s value), or dropped. A scenario whose only action is asetto the flag’s own value is not a collision and runs, as is a scenario that touches neither. A--params/--fixed-filefile is tier 3, below the scenario, and is not checked: a scenario overriding a parameter file is the intended layering.pfilterrefuses--scenariowith--paramat parse time (gh#194);fit predictandfit runtake no tier-5 flag;a scenario that
sets orscales a parameter a--sweep/--sweep-filegrid or a--draws <file>varies (simulate,fit predict,fit run --sweep; gh#572). Generated draws (--draws prior|uniform|posterior) are not checked: a scenario over them is the counterfactual those runs exist for;a scenario that moves a parameter off the value a fit.toml’s
[fixed]gives it, bysetorscale: onfit run(the fit.toml’s ownscenario), and onsurvey --fitandprofile --fit(the scenario from the fit.toml or the--scenarioflag). The error names the parameter, both values, the scenario and the fit.toml, and the fix: give[fixed]the scenario’s value to run under the scenario, or remove the parameter from the scenario. A scenario landing on the[fixed]value is not a collision and runs;a scenario that
sets orscales a parameter a fit.toml’s[estimate]lists, directly or through a preset it composes: onfit run(the fit.toml’s ownscenario) and onsurvey --fit(the scenario from the fit.toml or the--scenarioflag). A parameter is either estimated or given a value, never both: the estimate would discard the scenario’s value. The error names the parameter, what the scenario does to it and the fit.toml, and the fix: move the parameter from[estimate]to[fixed]with the scenario’s value to fit under it, or name a scenario that does not touch it;a scenario that
sets orscales a parameter an inline survey samples with--estimate. The box would discard the scenario’s value. The error names the parameter, what the scenario does to it and--estimate, and the fix: drop the parameter from--estimateto hold it at the scenario’s value, or name a scenario that does not touch it;a scenario that
sets orscales a parameterprofileestimates, with or without--fit. The estimated set is what--rw-sdselects: under--rw-sd auto, every parameter that is not swept,--fixed, in a--fixed-fileor in the fit.toml’s[fixed]; under--rw-sd NAME=SD, the names listed. A fit.toml’s[estimate]need not list them. The error names the parameter, what the scenario does to it and--rw-sd, and the fix: list the parameter in a--fixed-file(held out of the estimated set; the scenario’s value is still the one used, since--fixedon a scenario’s parameter is refused), drop it from--rw-sd NAME=SD, or name a scenario that does not touch it. A scenario setting the swept parameter is not refused: the grid replaces it, reported at info;[fixed] from_scenarionaming a scenario thatsets orscales a parameter[estimate]lists, directly or through a preset it composes: on every command that reads the fit.toml (fit run,profile --fit,survey --fit). The import would make the parameter both fixed and estimated. The error names the parameter, the scenario and the scenario’s value, and the fix: define a scenario that does not set the estimated parameters and name it infrom_scenario, or drop them from[estimate]to hold them at the scenario’s value (a scale-only parameter has the first fix only: the import has no value for the scale to multiply).
A fit.toml naming one parameter in both [estimate] and [fixed] is refused by every command that reads it (fit run, profile --fit, survey --fit); neither block wins.
[estimate] membership is narrowed by the same walk: a parameter named by a user-explicit --fixed/--fixed-file is removed from the estimated set — a layered override, reported as one info: line naming the parameter, the flag and its value — while a draw/sweep value never kicks a parameter out of [estimate] and a scenario touching an estimated parameter is refused (above).
Priors resolve on their own three-tier chain (fit.toml [estimate.p.prior] → the model IR’s ~ declaration → flat). For camdl profile the flat tier is a fallback that is recorded but not warned about; for camdl fit run it is reachable only by writing prior = { flat = {} } explicitly — an implicit fall-through to a flat prior is a hard validation error before the fit starts, and the resolved source per parameter is recorded in fit.meta.json.
1.4 Core Design Rules
One job type behind the verb. camdl simulate builds a SimulateJob (sim_job.rs) and hands it to one engine (engine::run_job), which plans the param-point × scenario × seed-slot grid and drives it through a RunSink; fit predict builds the same job for its free-forward replay. simulate’s sink writes per-cell store leaves plus a combined wide-format mirror. SimulateJob deliberately has no serde derive — the CLI parses its own surface (clap args) and lowers to the resolved struct, so there is no second wire schema to drift.
Every parameter choice is accounted for. In fit.toml the rule is exhaustive and enforced: every model parameter must appear in exactly one of [estimate] or [fixed]; a parameter in both, in neither, or in neither-and-not-in-the-model is a hard error before the fit starts. On the simulate side the rule is weaker: the resolver walks the tiers above and errors only if no tier supplied a value. Because a .camdl declaration carries no value, a simulate-side run needs a value for every parameter from a params file, a scenario, a draw or sweep, or --param. An [estimate] parameter the model gives no value is seeded with its [estimate].start, or a draw within its bounds when the entry has none, and recorded as estimate_start or estimate_start_drawn: a reference point, not any chain’s start, which init_provenance records. fit run, survey --fit and profile --fit seed through the same function, so beta = {} means the same in all three; a profile’s swept parameter takes its value from the sweep and needs no start. A refusal names the flags the command has (--params/--param on simulate, pfilter and eval; --fixed-file/--fixed on fit, profile and survey).
Sweeps are orthogonal to everything. A sweep is “run this thing at multiple parameter values,” and it works on both sides. simulate --sweep NAME=GRID (repeatable) or --sweep-file FILE varies a model parameter over a deterministic grid — the design axis (§5.5). fit run --sweep NAME=SPEC varies a [fixed] parameter, where SPEC is V1,V2,… | lin(min,max,n) | log10(min,max,n); sweeping a parameter that is in [estimate], or not in [fixed], is an error. Sweeps compose with scenarios and seeds by Cartesian product, provided the scenario does not touch a swept parameter (that is refused on both sides; see §1.3 and §3.6).
Draws and sweeps are different operations. A sweep is a deterministic grid the user designed; draws are samples from a distribution (posterior, prior, or uniform over bounds). They have different provenance and different downstream semantics, so they are separate variants of a sum type — ParamSource::Point, ::Sweep, ::Draws — never conflated. ParamSource::Draws further records whether the rows came from a user-authored file, because a scenario that patches a parameter the file also supplies is refused for an explicit file (the scenario would override that column in every draw), while over generated draws it is the intended counterfactual and the scenario’s value applies.
Provenance is structural. Every stored run carries a run.json recording its identity, its inputs, its lineage, its file manifest, and the column schema of its tabular outputs. Nothing is overwritten in place: a changed input produces a different identity and therefore a different directory, so a stale result is never silently served under a new configuration.
Reproducibility is structural. Every stored artifact is content-addressed by the resolved inputs that produced it. Same inputs → same run_id. M and σ stay distinct at the CLI (--param operates on M; --scenario/--enable/--disable operate on σ) and in the identity (they are separate hash levels), so a counterfactual can never be confused with a parameter edit.
2. Project Directory Structure
A camdl project is a convention, not a scaffold camdl creates or checks. The layout the vignettes and book chapters use is:
project/
├── models/ # .camdl sources
├── data/ # observation TSVs, lookup tables
├── params/ # parameter-point TOMLs
├── fits/ # fit.toml configs
└── results/ # the content-addressed store (see below)
Only results/ has meaning to camdl. Its location resolves with the precedence CLI --output-dir > config-file output_dir > CAMDL_OUTPUT_DIR > ./results (run_paths::output_root). Project-specific config sits above ambient shell state deliberately: setting CAMDL_OUTPUT_DIR to keep scratch runs separate must not silently redirect a fit.toml that declared output_dir = "results/he2010".
2.1 The content-addressed store
Every result camdl keeps lives in one store under the output root, partitioned by artifact kind:
results/
├── index.json # rebuildable lookup cache (§2.6)
├── .staging/ # atomic-commit scratch (§2.6)
├── sims/ # one leaf per simulated cell
├── ensembles/ # combined wide-format TSV across a multi-cell simulate
├── fits/ # fit segments, each holding its method leaves
├── pfilters/ # standalone particle-filter loglik evaluations
├── surveys/ # likelihood-landscape scans
└── profiles/ # profile-likelihood grid points
obs/ and projections/ are reserved partitions: their store directory and run-id tag are pinned but no leaf is emitted for them yet.
Identity is factored. A leaf’s identity is the ordered tuple of per-level content hashes along its path, and the store path is a readable nested factoring of that tuple rather than a flat blob directory. Each path segment is {label}-{hash8}: the label is provenance — renaming it yields a new directory, i.e. a harmless cache miss, never a wrong answer — and the hash8 is identity, the first 4 bytes of that level’s SHA-256 rendered as 8 hex characters. The leaf’s address is
run_id = SHA256( HASH_VERSION ++ kind_tag ++ level_count ++ [level hashes in path order] )
with the kind folded in as a fixed-width enum index and the level list length-prefixed, so two kinds with a coincidentally equal level sequence cannot alias and ([h1,h2], [h1]) cannot collide with a concatenation. The full 64-hex run_id and the full per-level hashes are recorded in run.json.
The levels per kind:
| kind | partition | levels, in path order |
|---|---|---|
sim |
sims |
model · config · params · scenario · seed |
sim_ensemble |
ensembles |
model · config · params · grid |
fit_method |
fits |
fit · method · seed |
pfilter |
pfilters |
model · config · params · seed |
survey |
surveys |
model · config · box · seed |
profile_point |
profiles |
profile · point · method · seed · start |
The one rule for consumers: resolve runs by reading run.json, never by parsing path segments. Path segments mirror the levels array for human navigation only. Do not infer kind, parameters, seed, or lineage from a path.
Two mechanics constrain the segment strings. A label is lowercased and every character outside [a-z0-9._-] is mapped to _, so hyphens and dots survive (chain_binomial-dt1, nl-sbplx, seed_42); a label longer than 200 bytes is truncated to a prefix and suffixed ..{hash16} so it fits inside the POSIX 255-byte NAME_MAX. If two distinct leaves would land on the same directory name because their short hashes collide at every level, the later one’s final segment gets a ~{disambiguator} suffix, escalating ~{hash16} → ~{full64} until unique. A reader enumerates sibling directories and reads each run.json rather than reconstructing an expected name.
Presentation is stripped before hashing. IR fields that only change how a result is rendered — output.format, simulation.time_semantics — are normalized out inside ModelDigest::from_model, which sits on every identity path, so a new artifact kind cannot silently opt out of the strip. A field that changes which values are computed or stored is identity and must re-key; a re-encoding of the same values is presentation and must not. Re-keys are deliberate, version-bumped events: a per-struct schema_version, the crate-wide HASH_VERSION, or ir/VERSION.
2.2 Fit Result Layout
A fit occupies a segment directory results/fits/{stem}-{fit_hash8}/, where stem is the slugged basename of the fit.toml and fit_hash8 is the fit-level content hash. The segment has no run.json of its own — it is a path level, not a leaf. Under it, one leaf per fit seed for the file’s [method]; a second method file of the same problem shares the fit hash and lands in a sibling segment named by its own stem:
results/fits/{stem}-{fit_hash8}/
fit.meta.json # fit-wide provenance sidecar (§2.2.1)
fit.toml.original # archived producing config
model.camdl.original # archived model source (when given as .camdl)
model.ir.json # archived compiled IR
model.render.json # archived display render (when given as .camdl)
model.graph.json # archived flow graph (when given as .camdl)
synthetic/truth.toml # [synthetic] fits only
synthetic/data/ds_NN/<stream>.tsv # [synthetic] fits only, one directory per
# sim seed, one file per stream
predict/{run_id12}[-{opts8}]/ # `fit predict` output for one run (§2.2.2),
# keyed by non-default predict options
predictive/<stream>.tsv
predictive.json # records the full run_id it replays
observed/<stream>.tsv
observed.json
quantities/<name>.tsv # `quantities {}` sidecar, when requested
quantities.json
report.json # what `fit predict` computed, and what
# refused (§2.2.2)
{method}-{method_hash8}/ # the [method], labelled by its algorithm
seed_{n}-{seed_hash8}/ # one per fit seed — the leaf
run.json
...method-specific artifacts...
The archived files and the predictive/observed/quantities outputs are regenerated sidecars: they are not part of any leaf’s file manifest, and a rerun of fit predict overwrites them in place. The predict outputs sit under predict/<run_id12>/, one directory per run they replay, and their manifests record that run’s full run_id.
What the fit hash covers. The fit level is a FitDigest over the whole model IR digest (which itself folds ir/VERSION and the engine version), the content digests of every training data stream, the content digests of every explicit [data.holdout] stream, the engine version, and the canonical JSON of the resolved fit config with three slices normalized out: method (the method level owns it), fit_seeds (the seed level owns the seed), and output_dir (pure write-location provenance). The fit level therefore hashes the problem alone: two fit.toml files that share a problem and differ in [method] share one fit hash and are separated on disk only by their stems, and editing a [method] key re-keys that file’s method leaf and nothing else.
Data files enter by content, not path: rewriting a holdout or training TSV in place re-keys the fit, so a stale held-out score cannot be reused. The same holds for chain-start sources — a from_posterior source’s draws.tsv, a from_mle source’s fit_state.toml, and a from_params TOML all fold their file digest into the method’s deps.
Method levels and lineage. The method level hashes the method’s identity payload (the algorithm block and the resolved starts rule), the number of stored posterior trajectories, the target chain length, the resolved observation alignment (exact or snap, per algorithm), and the method’s deps — the upstream artifacts a sourced starts rule consumes. Folding deps in is what makes cross-file invalidation work: a posterior file’s hash contains the leaf its from_mle = "@scout" names, so regenerating the scout re-keys the posterior. The method’s path label is the algorithm’s name (if2-<h8>, pgas-<h8>); there is no execution order among methods and nothing for an ordinal to encode.
Leaf contents by method. An IF2 leaf holds fit_state.toml, mle_params.toml, final_params.toml, chain_starts.tsv, chain_evaluations.tsv, diagnostics.tsv, and per chain chain_N/parameter_traces.tsv + chain_N/final_params.toml. A PGAS or PMMH leaf holds fit_state.toml, chain_starts.tsv, draws.tsv (the thinned post-warm-up cloud), diagnostics.json, and per chain chain_N/trace.tsv, chain_N/resume_state.bin, and — when posterior trajectories are requested — chain_N/trajectories.tsv with a chain_N/trajectories.json manifest.
mle_params.toml is written params-first with a trailing [provenance] table, so a bare toml reader still sees the parameters at the top level. The block records camdl_version, timestamp, content_hash (a tamper hash over the numeric values only, at 12-decimal precision, so editing a provenance field does not invalidate it), fit_hash, backend, dt, source_model, model_identity, per-stream [provenance.data] entries of source and hash, seed, method, chain (counted from 0, §2), log_likelihood, loglik_sd, n_particles, and optional ess_mean/ess_min. source_model and each data source are paths relative to the fit folder (fits/<stem>-<h8>/, two levels above the leaf holding the file), by the rule fit.meta.json’s source_model follows (§2.2.1), so neither depends on the directory the fit was run from; each is absent when its file did not exist when the fit ran. For IF2, log_likelihood is the loglik-eval re-score of the winning chain, so loglik_sd is that re-score’s Monte Carlo standard error (the ± on the stderr line), n_particles its particles per replicate (not the IF2 swarm’s size), and ess_mean/ess_min its effective sample size; an ODE-backend NLopt fit has no particle filter and records loglik_sd = 0 and n_particles = 0. camdl simulate --params reads backend/dt back to warn when a downstream simulation would run under different dynamics than the fit, and refuses the file when its parameter values no longer hash to content_hash (they were edited after the fit), naming the file: re-run the fit, or remove the [provenance] block to run the edited values as a plain params file.
2.2.1 fit.meta.json — fit-level sidecar
The fit segment carries a fit.meta.json holding the fit-wide provenance that has no RunRecord of its own. It is derived provenance, never a source of truth: everything in it is a readable projection of inputs already hashed into the leaf identities, written after identity is fixed and never fed back into any hash. Its top-level keys serialize in a fixed order (the writer’s field order, which is not alphabetical), and the keys inside each map (data_hashes, fixed, parameters_provenance, …) in sorted order (the maps are BTreeMaps), so two identical runs produce byte-identical sidecars and a diff of two runs’ metadata is meaningful.
Fields: schema_version (2; below), label (a profile’s label only; a fit’s label names one run and lives on that run’s run.json, see §6.8), source_model (the model file the fit ran on, a .camdl or .ir.json, as a path relative to the fit folder by the same rule as source_config; absent when the model was not a file; the archived copies are the folder’s model.camdl.original and model.ir.json), model_identity (the hex structural model hash), source_config (the producing config file, as a path relative to the fit folder that holds this fit.meta.json, below; absent for a CLI-only fit), fit_toml_hash (SHA-256 of the producing config’s raw bytes — provenance, i.e. which exact bytes produced this fit; a fit.toml handle is NOT looked up by it, see below), data_hashes, estimated, fixed, partition (the run’s parameter roles, below), resolved_priors (one {param, source} entry per estimated parameter, source ∈ {fit_toml, model_ir, flat_explicit, flat_fallback}, plus overridden_model_prior when the fit.toml’s prior displaced a model ~ prior; recorded whichever method ran, because every method run on the problem shares this segment and the resolution is a fact about the problem, not the method — flat_fallback appears only on a problem no posterior sampler can run on, since a sampler refuses an implicit flat prior before it starts), parameters_provenance (per parameter: resolved value, the tier that supplied it, and its role), training_window ({train_end}, present only when a [data] holdout declaration was applied, §6.2), dt (the integration step every run in the segment ran at — the model’s declared simulate { dt }, else the config’s [config] dt, else 1.0) and integrator (the ODE integrator the model declares, {"method": "rk4"} when it declares none — what an ODE-backend run integrated with), which a replay of the fit’s parameters runs on by default (§3.4; a sidecar written before these were recorded lacks them, and a replay recovers them from the archived config and model), schema (the observation/dimension schema below), and docs (the model’s #' documentation dictionary, keyed by declaration name, so a consumer can label any output column).
source_config and the archived config are two different files. fit.toml.original, beside fit.meta.json, is the archived copy of the config’s text, and is what every reader of a stored fit parses. source_config names the user’s own config file — which may since have changed or gone — and is recorded only for its location: the archive’s relative [model] and [data] paths were written against the source config’s directory, so a reader resolves them against <fit folder>/<source_config>, exactly as fit run resolved them against the config file. fit run computes the relative path between the canonical (symlink-resolved) fit folder and config, e.g. ../../../fits/sir.toml for fits/sir.toml and results/fits/sir-1a2b3c4d/, so it names the same file from any working directory and after the project directory moves as a whole. A fit whose results/ is moved apart from its configs and data no longer finds its data: the data are not archived in the fit (the store-lifecycle proposal’s pack takes that on). A data file the recovered config names but that is not there is refused with the stream, the path it was looked for at and the hash recorded for it in data_hashes:
error: the fit's data is not where its config says:
stream `cases`: looked for results/fits/sir-1a2b3c4d/../../../fits/../data/cases.tsv (recorded hash b39b0a43)
The config's relative data paths resolve against the source config's directory, …
schema_version. fit run writes 2, and every reader refuses any other value, naming it, rather than reading an older record’s fields as if they meant the same thing. A record without the key is version 1, whose fit_toml_path and model_path were relative to the directory fit run was started from:
error: …/fit.meta.json is fit.meta.json schema version 1; this camdl reads version 2 only (…). Re-run the fit with this camdl.
Two caveats a consumer must know. data_hashes holds, per observation stream the fit binds, the first eight hex digits of the SHA-256 of the stream’s data file, over the resolved stream set — so the [data] file = "..." shorthand records one entry per stream it binds, each the one file’s hash (gh#771); a holdout file is not hashed here. And fixed is projected from the base configuration, so under fit run --sweep every swept segment records the unswept value of the swept parameter; the segment’s fit-level hash, not this field, distinguishes the sweep points. estimated, fixed, partition and parameters_provenance are written per segment from that segment’s own resolution; the fit-level record before any segment has run leaves them empty.
partition is the authoritative record of the run’s roles. Its parameters map holds, per parameter, role (estimated or fixed), declared_by (fit_estimate or fit_fixed), and for an estimated parameter its support, its search box, the transform, and the prior that resolved (dist and from ∈ {fit_toml, model_ir, flat_explicit, flat_fallback}). estimated and fixed are generated from it. An IF2 fit records its priors in both places even though the method does not read them.
A third thing to know, because it is the one field whose name invites the wrong inference. fit_toml_hash answers provenance — which exact bytes produced this fit — and nothing resolves a handle by it. Handing a config to a verb (camdl compare fit.toml, camdl fit summary fit.toml) asks a different question: does this config MEAN what the stored fit was run from? That is answered by canonicalising the parsed value tree — comments, whitespace, table order, and the spelling of a float all discarded, [estimate] / [data.observations] order preserved because camdl reads those in order. Every run records that identity for each config it was written or reused from, in its run.json as inputs.config_identities, and a fit.toml handle names the run whose list holds the file’s identity as the file now reads — not the run the problem folder’s fit.toml.original (the latest run’s config) describes. Several such runs (several fit seeds, a --starts override, older camdl builds) are narrowed to the runs this camdl build wrote; if that leaves one, it is named, and otherwise the runs are listed. A run stored before runs recorded the identity cannot be matched by file and is named by its directory or run_id. Reflowing a comment changes fit_toml_hash and leaves the lookup identity alone; changing a particle count or a data path changes the identity and the config no longer resolves to that run.
The schema block lets a consumer facet any stream and label panels with no DSL parsing:
{
"schema": {
"dimensions": { "patch": { "levels": ["Bo", "Bombali"] } },
"streams": [
{
"name": "onset",
"index_dims": ["patch"],
"value_column": "onset",
"value_kind": "count",
"likelihood": "neg_binomial"
}
]
}
}dimensions maps each indexing dimension to its ordered levels (the union over all streams, in first-appearance order). streams carries one descriptor per logical stream, grouped by the from <label> data-source key, so a stratified onset[patch] is one entry with index_dims = ["patch"] and never one entry per expanded leaf. Each descriptor gives name, index_dims, value_column (the scored ~ left-hand side), value_kind (the DSL role — count / real / probability; omitted when the model declares no columns {} block), and likelihood (the family tag). The schema is a pure fold over the same observation leaves the particle filter binds, so it cannot disagree with what was fit.
2.2.2 fit predict predictive artifact
camdl fit predict <fit> resolves the fit handle — @label, a fit.toml path, a leaf or problem-folder directory, or a hash prefix — to one run, refusing a problem folder that holds several with the runs listed. It writes two tidy, plot-ready families of files under predict/<run_id12>/ in that run’s segment — outside the leaf, whose exact-set manifest admits no regenerated files — plus a JSON manifest for each that records the full run_id. Two runs of one problem keep separate predictives, and each file is attributable to the run it replays.
The directory is also keyed by the predict options that shape its output — --horizon, --n-draws, --seed, --stream, --scenario/--enable/ --disable, --sweep, --by-chain — resolved to the values used: the defaults write predict/<run_id12>/, any other set predict/<run_id12>-<opts8>/ (the first 8 hex of the SHA-256 of the options’ JSON), and predictive.json / observed.json record them under options. Two predicts of one run with different options are two artifacts, so neither overwrites the other, and fit table --quantity reads the address its own free-forward derivation writes. --exclude-chains and --quantities key sub-addresses inside the directory (predictive-excl3/, quantities-<key8>/).
The free-forward replay projects each row off its own recorded trajectories, so it records a snapshot at every time a row reads — each data time and each interval boundary — in addition to the model’s output schedule. A data record whose times fall between output times (data every 2.5 days against the default 1-day output) therefore needs no output { trajectories { ... } } line, which would re-key the model and orphan the fit. The extra snapshots are internal to the projection: they change no stored fit or run identity, and quantities/ stays on the declared output grid.
results/fits/<stem>-<hash8>/predict/<run_id12>/predictive/<stream>.tsv
scenario | sweep:<param>… | time | <dims…> | horizon | treatment
| fit_rhat_max | fit_ess_min
| rhat_mean | ess_mean | rhat_pred | ess_pred
| n_draws | q05 | q25 | q50 | q75 | q95
results/fits/<stem>-<hash8>/predict/<run_id12>/observed/<stream>.tsv
time | <dims…> | value
predictive/<stream>.tsv summarizes the model’s distribution over the observable as quantiles of sampled y_rep per (scenario, sweep point, time, stratum). Every axis is a column, so a new predictive cell is more rows and never new consumer code. The horizon column is free_forward (run the fitted model forward from the start, p(y_t | θ)) or one_step (p(y_t | y_{1:t-1}), re-running a bootstrap filter per posterior draw and pooling over particles × draws); both stack under one header. The two horizons walk the step the fit ran at — the model’s declared dt when it has one (the one dt rule), else the fit’s [config] dt — since either horizon on another step would replay a different process than the posterior describes. The treatment column is posterior when the band averages over the whole draw cloud — the only treatment the band-builder accepts today, enforced by typing rather than a runtime check, so a posterior-labelled band over a single point estimate is unrepresentable — with plug_in reserved. fit_rhat_max/ fit_ess_min are the producing leaf’s rank-normalized split R̂ and bulk-ESS of Vehtari et al. (2021) — the maximum R̂ and the minimum ESS over the leaf’s estimated parameters — empty when the leaf reported none, and fit_ess_min withheld entirely whenever any assessed parameter has no pooled ESS rather than minimized over the ones that do. Their names say whose numbers they are: the fit’s worst parameter, repeated identically on every row — provenance, not a statement about the value beside them.
Beside them sit two per-row convergence channels, both reducing the same draws the quantiles pool over but grouped by the chain each draw came from. rhat_mean/ess_mean reduce the latent expected value E[y | x_t, θ], before observation noise: “do the chains agree about the expected trajectory here?”. rhat_pred/ess_pred reduce the predictive draws: “do the chains give the same predictive distribution?”. The two are not interchangeable. A predictive draw carries observation noise, and that noise lands in the within-chain variance, so where it is comparable to the between-chain disagreement it swamps the numerator and rhat_pred is pulled toward 1 however much the chains disagree — worst on overdispersed counts. Decide on rhat_mean; read rhat_pred only for an interval genuinely dominated by irreducible observation noise. An empty cell is a refusal (fewer than 2 chains, fewer than 4 draws per chain, a draws.tsv with no chain column, a constant row), never a pass; the one_step horizon leaves both pairs empty, because its cell pools over filter particles as well as draws.
camdl fit predict --by-chain adds a leading chain column — all on the pooled rows, the chain id on one additional band per chain, on both horizons. The pooled band is unchanged and remains the default object; without the flag no chain column is written. A per-chain band carries no rhat_*/ess_* cell, because those compare chains, and reports its own chain’s n_draws.
The two horizons answer different questions per chain, and a reader must know which they are looking at. A free-forward per-chain band asks whether the chains project the same future; it runs free past the data, so a separation there mixes disagreement about the fitted trajectory with extrapolation uncertainty. A one-step per-chain band asks whether each chain explains the observed record; it is re-anchored to the data at every step, so a separation there is disagreement about the fitted trajectory itself, with the extrapolation removed. The one-step half is therefore the sharper statement about mixing, and the in-sample counterpart of the free-forward one.
Grouping the one-step pool by chain is a regrouping of the same reduction, not a decomposition of the particle contribution: each chain’s band pools its own draws × particles exactly as the pooled band pools all of them, so the particle pooling affects a per-chain band’s width the same way it already affects the pooled band’s. That pooling is why one-step rows carry no rhat_*/ess_* at all (an ESS from an autocorrelation over draws × particles is inflated by the particle count — deferred, gh#798); it is not a reason to leave the rows pooled.
A chain whose one-step filter degenerated on some of its draws still bands, and its n_draws reports what it actually pooled rather than what the subsample gave it, so a narrower band there is legible as a thinner sample; the loss is named on stderr. A chain that lost every draw contributes no band at all and is named too — banding an empty cell would publish NaN quantiles, which reads as a band.
--by-chain adds no artifact address of its own, and composes with --exclude-chains, which does. A --by-chain file is a strict superset of the pooled one — its all rows are byte-identical, so re-running with the flag adds rows and one column rather than replacing an artifact with a different object, exactly as --scenario and --sweep do. A chain subset is a different posterior, so it keeps its own keyed directory, and the two compose: camdl fit predict --by-chain --exclude-chains 3,5 writes predictive-excl3,5/<stream>.tsv with a chain column. Chain ids are the fit’s own numbering and are never renumbered by an exclusion, so an excluded chain is simply absent from the column and the ids that remain name the same chains as in the pooled artifact.
n_draws is the cloud size behind each band. The scenario column is fitted for the one-step rows, which are scenario- and sweep-agnostic by construction: filtering observed data through a counterfactual model is ill-defined. The one-step filter runs the fitted model — the fit’s own scenario / enable / disable applied (§11.3) — so it fires exactly the interventions the fit’s likelihood fired.
observed/<stream>.tsv is the recorded value per (time, stratum) in the same tidy keys. A hole — a scheduled but unobserved cell — renders as an empty value, distinct from an observed zero. A consumer reads both files, joins on (time, <dims>), and plots observed over the predictive ribbon, one facet per stratum, using index_dims from the fit.meta.json schema and no run-store, DSL, or likelihood knowledge.
predictive.json (schema tag camdl.predictive/v3) records the run_id whose draws it replays and declares, per stream, which columns are join coordinates versus band versus per-cell diagnostics, the value_kind, and the band’s quantile levels; it also carries a chain_selection block when --exclude-chains narrowed the cloud, so a chain-subset band is never mistakable for a full-cloud one.
The tag is load-bearing, and a stored artifact must be read under its own. The three versions do not carry the same numbers under the same column names:
| tag | the two fit-provenance columns | what they hold |
|---|---|---|
v1 |
rhat_max, ess_min |
classic Gelman–Rubin R̂ and a Geyer per-chain sum |
v2 |
rhat_max, ess_min — the same names |
rank-normalized split R̂ and bulk-ESS (gh#84), ess_min withheld rather than minimized when any parameter has none |
v3 |
fit_rhat_max, fit_ess_min |
the v2 statistics, renamed to say whose they are, plus the per-row rhat_mean/ess_mean/rhat_pred/ess_pred channels |
The v1 → v2 row is why the tag exists at all: the estimator changed and the column names did not, so for those two versions the tag is the only signal that two artifacts in one store hold different quantities. Joining them without keying on it compares a classic R̂ against a rank-normalized split R̂, which are not the same number. The v3 rename is the first time that distinction is visible in the column name itself. observed.json (camdl.observed/v1) is its sibling for the observed series.
report.json says what the run computed and what refused. Schema tag camdl.predict-report/v1, written on every fit predict, with a failures list that is empty when nothing refused:
{
"schema": "camdl.predict-report/v1",
"failures": [
{
"kind": "evaluation_failed",
"at": "free_forward",
"reason": "scenario 'longer' declares …"
}
]
}A deterministic failure is a fact about the model as written that holds for whatever produced it regardless of how often it occurs — an unresolvable horizon, a scenario window this verb cannot honour, a NaN/±∞ where a number was about to be banded. One occurrence is a defect, so the run exits 1. What it does not do is discard the objects that computed. Two kinds are recorded:
evaluation_failed, at"free_forward"— the tail as a whole refused. The one-step-ahead band is data-conditioned and never reaches the forecast horizon, so it cannot be invalidated by this: it is written, the tail’s failure is recorded, and the run exits 1.--horizon one_stepremains the way to ask for that half alone — and because such a run never builds a tail, a scenario it could not have honoured is a plain usage error there, refused outright.non_finite, at{"quantity": "<name>"}with thedrawthat produced it — onequantities {}entry could not be banded. That entry writes no file and gets no manifest entry; every other quantity and every stream artifact is written, and the run exits 1. An entry that refused in one design cell is dropped from all of them, since the cells share one file and a partial file reads as a table over every cell.
simulate renders the same quantity tables but has no report to write into, so a non-finite quantity is an error there, named, with nothing written.
A chain subset is a different address, not a rewrite of the pooled one. fit predict --exclude-chains 3,5 writes predictive-excl3,5/<stream>.tsv + predictive-excl3,5.json, and likewise contrasts-excl3,5/, quantities-excl3,5/ and report-excl3,5.json; the full cloud keeps the bare names. The key is the excluded set, so 3,5 and 5,3 are one address, and it composes with the reporting-vocabulary key (quantities-<key8>-excl3,5/). Without this a subset would silently replace the run’s canonical predictive and a second subset would replace that, leaving only a stamp inside the file that had already been overwritten. observed/ is not keyed — it is the recorded data, identical under any selection. camdl show <fit> lists every address a fit holds, and camdl cat <fit> --stream <address>/<stream>.tsv reads one.
Calendar semantics travel with the artifact. predictive.json, observed.json, quantities.json, and the per-chain trajectories.json each carry a top-level calendar block, produced by one shared emitter so every exporter agrees:
"calendar": { "time_unit": "days", "origin": "1910-01-01", "days_per_unit": 1.0 }
time_unit is the model’s (days / weeks / months / years); origin is the model’s ISO YYYY-MM-DD origin, or null for a bare-numeric-time model — the signal to plot time numerically. days_per_unit is the exact length of one time_unit in days (days=1, weeks=7, months=365.2425/12, years=365.2425), exported so a consumer converts with no hardcoded unit table. A consumer maps time → date as origin + time · days_per_unit days. A calendar-anchored model is constrained to days/weeks (camdl rejects origin with months/years, E320), so wherever there is an origin to map, days_per_unit is 1 or 7 and the mapping is exact and identical to camdl’s own rendered date column. The model’s internal origin_rata_die is deliberately not exported: it is keyed to camdl’s own epoch and would mislead a consumer that read it as a Julian or Rata day number. Numeric time stays the canonical, diff-stable axis; calendar is additive metadata, not a re-encoding of it.
2.2.3 Which object a quantity is read on
fit predict also writes the model’s quantities {} block to quantities/<name>.tsv plus a quantities.json manifest. Those numbers are not all folded over the same object, and the manifest says which.
Every banded row there carries rhat and ess after n_draws: the rank-normalized split R̂ and bulk-ESS of that row’s reported value, reduced over the same draws the band pools but grouped by the chain each draw came from. One pair, not two, because a quantity has a single value per draw. These are the reported estimands, so they are the first convergence numbers to read — and they routinely disagree with the parameter R̂ in either direction: a reportable quantity is often far better determined than the parameters behind it. What the value carries decides which kind of R̂ it is: a state or derived quantity is a deterministic function of the trajectory and the parameters, so its R̂ is undiluted; a quantity whose manifest source is observations reduces sampled y_rep, carries observation noise, and so reads like rhat_pred. simulate --quantities-out has no chains behind it and writes neither column.
A quantity anchored inside the observed record — value_at(EXPR, last_obs), value_at(EXPR, first_obs + 2 'weeks), or a literal time at or before the last observation — is a retrospective estimand: there are observations covering the instant it reads, so it is folded over that draw’s conditioned smoothing path p(x | y, θ), saved by the fit at chain_N/trajectories.tsv. Everything else is folded over the free-forward replay the predictive band is built from: a reduction with no anchor (final, max, mean, time_of_max, integral, a threshold crossing) is a property of a whole simulated path, and an anchor past the last observation (last_obs + 8 'weeks) is a projection.
Each manifest entry carries the tag:
{ "name": "outbreak_size", "evaluated_on": "smoothed", "n_conditioned_draws": 300 }
{ "name": "peak_infectious", "evaluated_on": "replay" }
evaluated_on is smoothed, replay, or replay_unconditioned — the last for a quantity that reduces observations.<stream> at an in-window anchor, which stays on the replay because a saved path carries the conditioned projection (inc_<stream>, a mean), not a draw from it. That case is announced on stderr.
Three consequences a reader has to know:
- Only the saved subset of draws has a conditioned path. The trajectory save stride (
n_trajectories) andthinneed not agree, so a smoothed quantity bands over the draws that have one; the rest are censored, counted inn_censored, never given the replay’s answer.n_conditioned_drawsis the band’s real denominator, and the count is also printed on stderr. - A counterfactual arm keeps the replay. A
--scenario/--sweepcell, or a run carrying--enable/--disable, replays a different model than the one the smoothing path was inferred under, and the data a counterfactual would have generated do not exist. Those cells are taggedreplay. - A fit that saved no paths reports the quantity as fully censored, with a named note on stderr, rather than falling back to the replay.
2.3 Sweeping a fit over a fixed parameter
camdl fit run <config> --sweep NAME=SPEC applies each sweep point to the resolved [fixed] block before identity is computed. A sweep point is therefore a different fit — different [fixed] values give a different FitDigest — and each point gets its own fit segment, a sibling directory sharing the stem:
results/fits/03_rho_sweep-{h8_for_rho_0.5}/if2-{h8}/seed_1-{h8}/
results/fits/03_rho_sweep-{h8_for_rho_0.7}/if2-{h8}/seed_1-{h8}/
There is no nesting of sweep points under a shared fit directory: within a segment, the layout of a swept fit is identical to that of an unswept one.
2.3.1 Concurrency: the leaf lock
The store enforces concurrency at the leaf, in two write modes.
Mode A (atomic) — used by sim and sim_ensemble, whose whole output is known in memory before the commit. The leaf is written into a per-attempt staging directory under <root>/.staging/, fsynced in a fixed order (each artifact, then run.json, then the directory), then moved into place by rename. The staging name carries the process id and a process-local counter, so two concurrent same-identity commits never share a staging directory.
Mode B (streaming) — used by fit methods, pfilter, survey, and profile points, which write into the leaf as they go and whose display summary (a loglik, a landscape score) is only known at the end. The writer creates .lock with O_EXCL inside the leaf directory, writes a Running run.json, streams its files, and commits by flipping status to Completed and building the file manifest from what it actually wrote. A concurrent claimant that finds a live .lock gets CasError::FitInProgress naming the holding pid.
A .lock holds its owner’s pid and host name, one per line (a lock holding the pid alone, as camdl wrote before the host was recorded, is read as this host’s). Whether its owner is alive is decided in one place (runid::store::lock_owner): absent; unreadable (treated as held); alive or dead on this host; or on another host, whose liveness this host cannot check.
A crashed writer leaves its .lock and a Running run.json behind. A re-run reclaims such a leaf when its owner is a dead process on this host (keeping its record, below). A leaf held from another host is never reclaimed automatically and never reported as interrupted: the claim refuses it (OwnedOnOtherHost), naming the host and pid, and --force (fit run, survey) is the way to clear it, moving the leaf, records included, into the store’s .quarantine/. The reclaim: it liveness-checks the recorded pid (kill(pid, 0) on unix), and a dead holder’s lock is removed and the leaf re-claimed. Reclaim is serialized through a second .reclaim lock — only the .reclaim holder may remove .lock, and it re-confirms the holder is dead while holding .reclaim, so two processes can never both delete the lock and both proceed. On a non-unix platform the pid cannot be checked, so a held lock always reports FitInProgress. As defense in depth, a claimant that wins the lock and then finds a Completed record refuses to clear it (ReclaimRaceCompleted) rather than blind-wiping a finished result, and a claimant that finds a run.json it cannot read (permission denied, an IO error) refuses the leaf (RecordUnreadable) rather than quarantine or clear it: an unreadable record is not an absent one. A leaf directory with no record is quarantined as debris only by a claimant holding its .lock, and only if it still has no record and holds something besides the lock: from outside the lock, a directory a concurrent claimant has just created, or has just finished, looks the same as debris.
A run’s end is recorded, never inferred. A run that completes is committed Completed. A run camdl stops on an error is committed Failed before the process exits, with a failure record — {"message": …, "operation": …}, the error camdl printed and the method or command that was running — and its .lock released. Nothing else writes Failed: a Running record whose owner died is an interrupted run, and stays Running. A reader tells the two apart from the .lock alone — Running with a .lock naming a live process is in flight; Running with no .lock, or one naming a dead process on this host, is interrupted; Running held from another host is of unknown liveness — and never rewrites the record to say so. A Failed record with no failure is one a camdl before the field wrote, or one the claim’s drop guard wrote on a panic: readable, its reason unknown.
A retry keeps the prior attempt’s record. A re-run that reclaims a leaf of its own identity (failed, interrupted, or stale) first moves the prior attempt’s leaf-root files into .attempts/<n>/ inside the leaf, n counting attempts in the order they were preserved: its run.json (renamed attempt.json, so no walk of the store finds a second leaf of one identity), events.jsonl, diagnostics.json, progress.json, chain_starts.tsv and any other file at the leaf root. Its directories (chain_N/, trajectories/) are bulk output, not the record of the attempt, and are cleared with the rest. A leaf keeps at most three attempts (MAX_PRESERVED_ATTEMPTS); preserving a fourth deletes the oldest. .attempts/ is outside the exact-set manifest and the orphan scan, so a retry that completes is a clean Completed leaf, a cache hit, beside its preserved attempts. --force quarantines the whole leaf, attempts included.
Cache semantics. lookup reports one of four outcomes for a path against an expected identity: Hit (identity matches, status Completed, file manifest intact), Miss (no run.json), Stale with a reason (Incomplete — not completed or unparseable; Corrupt — a listed file is missing or its size/mtime changed; OrphanFiles — an undeclared file or directory is present; SchemaDrift — a stale hash_version/format_version), or Collision (a different full identity occupies this path via a short-hash collision, in which case the incumbent is never touched and the newcomer is disambiguated). The integrity gate is deliberately cheap — presence, size, and mtime, no re-digest — so listing a fit with many large chain files does not re-hash them; the recorded per-file SHA-256 is there for integrity tooling, and no read path recomputes it today. The manifest is an exact set over the leaf’s whole subtree, except reserved files and declared children: a declared child subdirectory (obs/) is a boundary, recognized and not recursed into.
Which commands skip on a cache hit. fit run consults lookup and skips the computation on a Hit (unless --force); profile skips each start whose leaf is a Hit (reported as N of M starts already cached) and has no --force. A multi-cell camdl simulate skips a cell the store already holds under the exact rule of §4.4 and reads its traj.tsv back for the combined mirror; a lone simulate runs its cell and relies on the commit being idempotent.
2.4 Simulation Result Layout
A single-cell simulate writes one sim leaf:
results/sims/{model}-{h8}/{backend}-dt{dt}-{h8}/{params}-{h8}/{scenario}-{h8}/seed_{n}-{h8}/
run.json
traj.tsv
event_log.tsv # with --event-log on a LINEAGES-capable backend
reactive_log.tsv # when a reactive policy was active
obs/{obs_h8}-{obs_seed}/{stream}.tsv + obs.json # declared child, with --obs
The five levels hash disjoint slices of the resolved input set. model is the presentation-normalized whole-IR digest plus ir/VERSION and the engine version. config is backend, dt, t_start, t_end, the output schedule, allow_degenerate_rates, and the trajectory column view (--no-flows, --columns); --output-every is not here because it is lowered into the output schedule upstream. params is the resolved base name→value map plus the digests of any --table files. scenario is the scenario’s effective delta — the enabled and disabled intervention sets, the parameter patch (set), and the scale factors, each including whatever the scenario inherits through compose — so two presets that differ only in scale or only in what they compose cannot share a run_id. seed is the resolved process_seed (the only hashed field of the level).
Segment labels: the model label is the model file’s slugged stem; the config label is {backend}-dt{dt}; the params label is base when no per-cell overrides apply, otherwise the sorted name=value pairs joined by _ (sanitized), falling back to the literal draws when that would exceed 96 characters; the scenario label is the preset name, or for the implicit arm of a run without --scenario the label derived from its flags — (none) when it has none, which the path sanitizer renders _none_ — and the seed label is seed_{N}, unpadded, where N is the cell’s process_seed: the --seed value itself for a lone run, for replicate 0 at point 0, and for each value of an explicit --seeds list, and otherwise the mixed value of §3.1 (for example seed_5871781006564002452). A label is provenance, never identity: a leaf written under another label for the same hashes (an earlier camdl wrote the implicit arm as baseline) is found by hash and reused.
Because a level hash is a structural digest of typed values rather than of a string, the empty scenario is a constant: a scenario with no overrides, no scale factors, no enables and no disables always hashes to the same value, so _none_-e93c95fd identifies the unmodified model in every project. seed_1-e6a47b86 and base-... are constants in the same way.
A multi-cell simulate (--replicates, --seeds, several --scenario, or --draws) writes the N per-cell sim leaves and one sim_ensemble leaf under ensembles/ holding the combined wide-format ensemble.tsv that interleaves every cell with replicate / scenario / draw columns. The ensemble’s grid level digests the sorted cell list — each cell contributing (scenario_label, process_seed, draw_idx, sim_run_id) — plus the explicit cell count, so three replicates and four replicates are different ensembles (count-in-the-key), and the ensemble deps on the N sim leaves it was built from. Its path label is cells-n{N}.
2.5 Per-leaf metadata
Every leaf directory contains exactly one run.json, the serialized RunRecord. It records the leaf’s identity levels, the exact set of files the leaf owns with their digests, the upstream artifacts it consumed, its completion status, and a free-form provenance block. It is never itself hashed — identity comes only from the resolved inputs, which is precisely why attaching an obs/ child or a column schema cannot change the parent’s run_id.
run.json also carries an output_schema declaring, for each tabular file the leaf wrote, which column is the time or iteration axis, which are grouping keys, which are model quantities, and which are sampler diagnostics — so a consumer can render or join a TSV without reverse-engineering its header.
The full field-by-field contract is §9.5; the column-role vocabulary and the rendering rule it implies are §10.9.
Sub-artifacts have no run.json. A trajectory leaf may declare an observation ensemble as a child at obs/{obs_hash8}-{obs_seed}/, holding one <stream>.tsv per observation stream plus an obs.json provenance file. That child is keyed on the model’s observation blocks alone, so changing a reporting parameter re-samples the observations without invalidating the cached trajectory. Reach it through the parent’s children map, not by walking the tree for run.json files.
2.6 Store-root files
<root>/index.json is a rebuildable derived index mapping each run_id to its path and summary fields, so prefix resolution for show and cat does not re-walk the whole tree on every call. It is a cache and never authoritative; §9.6 gives the two invariants that make that operational rather than aspirational.
<root>/.staging/ holds in-flight atomic commits (§2.3.1). Entries left behind by a crash are inert debris; they are never read.
3. Core Types
A run is a point in three axes — M (parameter values), σ (scenario), and S (seed) — and the job of this section is to say which type owns which axis, where a value stops being a request and becomes a resolved fact, and how that resolved fact becomes a directory on disk.
Three type families do the work, and conflating them is the usual source of confusion:
| Family | Purpose | Lives in |
|---|---|---|
| Surface types | Parse the CLI surface: clap args | rust/crates/cli/src/args/ |
| Engine input | The resolved job the surface lowers to | rust/crates/cli/src/sim_job.rs |
| Identity values | Hashed, per-level digests of the fully-resolved inputs | rust/crates/runid/src/inputs.rs |
The pipeline is:
camdl simulate <args> ─→ SimulateJob ─→ engine::run_job ─→ per-cell SimRun
(resolved) (cell grid) (resolved θ, σ, seed)
│
runid identity values ←──────────┘
(ModelDigest, SimConfig, ResolvedParams,
ResolvedScenario, Seed)
│
five LevelIds → run_id → store path
SimulateJob deliberately has no serde derives: the surface lowers to one resolved struct, and a wire format for that struct would be a second schema to keep in step with the flags.
3.1 SimulateJob — the resolved engine input
rust/crates/cli/src/sim_job.rs
#[derive(Debug, Clone)]
pub struct SimulateJob {
/// Resolved IR/`.camdl` path, verbatim from the CLI.
pub model: String,
/// `--params` files, applied in order.
pub params_files: Vec<String>,
pub backend: ForwardBackend,
pub dt: f64,
/// Optional CLI `--integrator` override (method only); `None` → the
/// model's declared integrator.
pub integrator: Option<crate::args::types::IntegratorArg>,
/// Where parameter vectors come from (the central dispatch).
pub source: ParamSource,
/// σ layer — which scenarios to run. Empty ⇒ the single implicit arm, `(none)`.
pub scenarios: Vec<ScenarioRef>,
/// The resolved `--to` horizon override (model time), applied to every cell
/// after the scenario horizon; `None` = no override.
pub t_end_override: Option<f64>,
/// Times every cell must record a snapshot at, beyond the model's own
/// output schedule (`fit predict` supplies its forecast rows' closing
/// boundaries; empty everywhere else).
pub required_output_times: Vec<f64>,
/// The loaded `--init-state` ensemble, shared by every cell; `None` = the
/// model's own `init {}`.
pub init_state: Option<std::sync::Arc<InitStateSource>>,
/// The run's resolved observation window, folded once for the whole job so a
/// sweep cannot resolve a different `last_obs` per cell.
pub obs_anchors: Option<ir::anchor::ObsAnchorTimes>,
/// S layer.
pub seeds: Seeds,
/// `--param NAME=VALUE` CLI overrides merged on top of every cell
/// (M layer, highest precedence).
pub cli_overrides: Vec<(String, f64)>,
/// A `--draws prior|<file> --fit FIT.toml` config's `[fixed]` block,
/// resolved at the `[fixed]` tier of every cell; empty for every other
/// source.
pub fit_fixed: IndexMap<String, f64>,
/// `--param-vec PREFIX=FILE` entries (CLI only).
pub set_vec_entries: Vec<(String, String)>,
/// `--table NAME=FILE` entries (CLI only).
pub table_files: Vec<(String, String)>,
/// Synthetic-observation output mode.
pub obs: ObsOutput,
/// Rayon thread count for the simulation phase (1 = sequential).
pub parallel: usize,
}It carries no output directory, no --force, no label, and no GeoJSON path: those are write-side concerns owned by the sink, not by the job. camdl simulate builds one in main.rs with parallel from --parallel / CAMDL_PARALLEL (absent: sequential; 0: every core); fit predict builds one for its free-forward replay and passes its own thread count.
The engine that consumes it is rust/crates/cli/src/engine.rs:
pub fn plan_grid(job: &SimulateJob) -> (Vec<CellSpec>, Grid);
pub fn run_job(job: &SimulateJob, sink: &mut dyn RunSink) -> Result<(), String>;plan_grid expands the job into an ordered list of cells — the Cartesian product scenario × param-point × replicate, in exactly that iteration order — and run_job runs them (sequentially, or via Rayon when parallel > 1) and merges each into a RunSink in canonical order. simulate supplies a sink that commits a content-addressed leaf per cell and accumulates the combined wide-format TSV.
Per-cell seeds are order-independent by construction, so parallelism never perturbs a trajectory:
process_seed = if explicit --seeds { seeds[rep] }
else if total_runs == 1 { base_seed }
else { base_seed ^ (seed_point_idx * SEED_MIX_DRAW)
^ (rep * SEED_MIX_REP) }
obs_seed = process_seed ^ SEED_MIX_OBS
seed_point_idx is the point (draw-row) index for every parameter source except a sweep, and is always 0 for a --sweep / --sweep-file design: a sweep point is a value the user chose, not a sample, so every design point at a given replicate shares one random stream (paired differences across points, and inserting a point leaves the others’ trajectories unchanged; §5.5).
The scenario index is deliberately absent from the mix: paired scenarios at the same (point, replicate) share a seed, which is what makes their pre-divergence trajectories byte-identical (common random numbers). The mix itself lives once, in util::mix_cell_seed; tests/determinism_pin.rs pins it.
3.1.1 ObsOutput — synthetic observation output
rust/crates/cli/src/sim_job.rs
#[derive(Debug, Clone, Default, PartialEq)]
pub enum ObsOutput {
#[default]
None,
/// Single wide-format TSV (errors if streams have different schedules).
File(PathBuf),
/// One TSV per stream in a directory.
Dir(PathBuf),
/// Like `File`, trajectory suppressed.
OnlyFile(PathBuf),
/// Like `Dir`, trajectory suppressed.
OnlyDir(PathBuf),
}Three accessors keep the interpretation in one place: suppresses_trajectory(), file_path(), dir_path().
CLI mapping:
--obs cases.tsv → ObsOutput::File("cases.tsv")
--obs-dir obs/ → ObsOutput::Dir("obs/")
--obs-only cases.tsv → ObsOutput::OnlyFile("cases.tsv")
--obs-only-dir obs/ → ObsOutput::OnlyDir("obs/")
These four flags are mutually exclusive at the clap level. All of them are mirrors: the observation draws are also written into the run’s leaf under obs/{obs_hash8}-{obs_seed}/.
3.2 ParamSource — where parameter vectors come from
rust/crates/cli/src/sim_job.rs
Exactly one variant is active per job. It answers “how many parameter points is this job, and where did each one’s values come from?” — not “how were they specified”; the recipe (a linspace, a --draws prior -n 500) is resolved to concrete rows by the front end before the job is built.
#[derive(Debug, Clone)]
pub enum ParamSource {
/// Single point: base params + CLI overrides, run `replicates` times.
Point { replicates: usize },
/// Deterministic grid: the design points, each with the id it is written under.
Sweep { points: Vec<SweepPoint>, replicates: usize },
/// Pre-resolved parameter draws (posterior file / prior / uniform).
Draws {
rows: Vec<IndexMap<String, f64>>,
replicates: usize,
/// `Some(path)` iff the draws came from a user-authored file
/// (`--draws <file.tsv>`); `None` for generated draws.
explicit_file: Option<PathBuf>,
},
}
/// One design point: the id it is written under (a sweep file's own column,
/// else its 0-based position) and the parameter values it sets.
pub struct SweepPoint {
pub point_id: usize,
pub overrides: IndexMap<String, f64>,
}Accessors: param_points() returns the ordered per-cell override maps (a single empty map for Point), point_id(i) returns the id point i is written under (a sweep point’s own point_id, else i), and replicates() returns the replicate count. Total cells are |scenarios| × |param_points| × replicates, except that an explicit seed list overrides the replicate count — see §3.5.
Which front end produces which variant:
| Variant | Produced by |
|---|---|
Point |
simulate with no --draws (--replicates N sets the count) |
Sweep |
simulate --sweep … / --sweep-file FILE — the design axis (§5.5) |
Draws |
simulate --draws … (all four sources of §3.4) |
explicit_file is what distinguishes a user-authored draws file from generated draws, and the distinction has teeth. A scenario whose set or scale touches a parameter that a user-authored coordinate varies — a Sweep point set, or a --draws <file.tsv> column — is refused before any cell runs, naming every colliding parameter, both sources, and the fix (the diagnostic is shown in §3.6). Over generated draws the same overlap is allowed: a scenario applied to prior, uniform, or posterior draws is the counterfactual those runs exist for. The check is engine::check_scenario_coordinate_collision, reached from engine::run_job and from simulate --dry-run (so a dry run refuses what the run would); fit predict --sweep words its own check with the same formatter.
This is collision policy, not precedence. The resolution order of §1.3 still places a scenario above a draw row or sweep point, and that order is what decides distinct parameters and generated draws. It never silently discards values the user listed.
3.3 Sweep specification
One sweep grammar serves every verb that takes a grid.
--sweep NAME=SPEC — rust/crates/cli/src/args/types.rs, used by camdl simulate, camdl profile, camdl fit run and camdl fit predict. simulate and fit predict share one expansion (rust/crates/cli/src/sweep.rs): several --sweep flags form the Cartesian product sorted by parameter name, the first name varying slowest, and point_id is a point’s 0-based position in it, so the two verbs give the same point_id for the same flags. simulate also reads a grid from a file, --sweep-file FILE (§5.5).
pub struct SweepSpec { pub name: String, pub grid: Grid }
pub enum Grid {
List(Vec<f64>), // beta=0.1,0.2,0.3
Linear { min: f64, max: f64, n: usize }, // beta=lin(1.0,4.0,11)
Log10 { min: f64, max: f64, n: usize }, // k=log10(0.001,1000,7)
}lin and log10 require n >= 2 and min < max; log10 additionally requires min > 0. The function is spelled log10, not log, because the camdl DSL binds log to the natural logarithm inside rate expressions — writing log(...) here is a hard error with a redirect rather than a silent reinterpretation.
3.4 Parameter draws
Draws are parameter vectors sampled from a distribution — a different object from a designed sweep grid. A sweep is a design you chose; draws are samples from inference output or a prior, and downstream analyses need to know which they are looking at.
There is no DrawsSpec type. simulate --draws resolves its argument to concrete rows before the job exists, and the rows enter as ParamSource::Draws.
--draws <value> |
Source |
|---|---|
<file.tsv> |
A TSV of parameter vectors (one row per draw, columns = parameter names). |
prior |
Sample declared priors. Requires -n N. --fit <fit.toml> is optional. |
uniform |
Sample uniformly from each parameter’s in [lo, hi] bounds. Requires -n N. |
posterior |
A completed fit’s canonical post-warm-up draws.tsv. Requires --fit <fit results dir>. |
--draws prior resolves priors through the same three-tier precedence chain camdl fit run uses — fit.toml [estimate].prior first, then the model IR’s ~ <dist> declarations, then a flat fallback. With --fit, the sampled parameters are the fit’s [estimate] names, and one of them with no usable prior in either tier is a hard error naming the remediation. With no --fit it samples the parameters that declare a ~ <dist> prior in the model’s parameters {} block; every other parameter is left out of the row and resolves per cell. Either way, a run that would sample no parameter at all (no --fit and no ~ prior in the model, or an empty [estimate]) is a hard error: every draw would be the same parameter vector. Do not describe this source as requiring a fit config.
--draws uniform samples lo + (hi - lo)·U for each parameter that declares in [lo, hi] bounds. A parameter without bounds is not drawn: it resolves per cell from --params, the scenario, or --param, and the run is refused up front if none of them sets it. A model in which no parameter declares bounds is a hard error: every draw would be the same parameter vector. The error says what is sampled (each parameter’s declared bounds) and where to declare them (name : <type> in [lo, hi] in parameters {}). This is space-filling exploration for model debugging, not a prior.
--draws posterior --fit <fit results dir> reads the draws the fit’s sampler (PGAS / PMMH / MH) wrote — the method leaf’s draws.tsv, which is post-warm-up and thinned and carries every model parameter. Resolution is by artifact, not by method name: a leaf has a posterior iff it wrote a draws.tsv, so an optimizer-only fit (IF2 / NLopt) resolves to an error rather than to a single point dressed up as a distribution (rust/crates/cli/src/posterior_draws.rs).
--draws <file.tsv> --fit <config-or-run> additionally supplies the fit’s [fixed] block to every parameter the file’s columns lack — a raw posterior trace tail carries only the estimated columns, so this fills the fixed parameters from the fit rather than leaving them unset. The block resolves at its own tier (2 of §1.3), as on --draws prior --fit: --params and the scenario outrank it, and a column the file carries (a row value, tier 3.5) outranks it. The values are not copied into the rows, so --draws-out writes only the file’s own columns.
The model on the command line must be the model the parameters belong to. Every --fit form above replays a fit’s parameters — its posterior draws, its [fixed] block, its priors — through the model named on the command line, and those parameters mean something only under the model they were written or estimated for. So before anything runs, simulate compares the two:
- a stored fit (a run or problem-folder directory,
@label, run-id prefix, or afit.tomlnaming its run under--draws posterior) is compared with the model the fit archived,<fit-dir>/model.ir.json; - a fit config (
--draws prior|<file> --fit fit.toml) is compared with the model its[model]names, compiled now.
The comparison is the one fit predict --quantities uses (rust/crates/cli/src/fit/fitted_model.rs, compare_to_fitted): the presentation-normalized model identity, which excludes quantities {} and contrasts {}, the model’s file name, and --output-every (a property of this run, applied after the check). A model that differs is refused, naming the fit, both identities, and the fix:
error: --fit results/fits/mh-8d0c7e80: edited.camdl is not the model this fit ran on (fitted model 9bca0399, edited.camdl e905ea63), so its parameters would be replayed through different dynamics. Quantities and contrasts are excluded from this comparison: the difference is in the dynamics, observations, parameters or simulation settings.
Fix: run with the fitted model, results/fits/mh-8d0c7e80/model.ir.json (the fit's archived IR), or refit edited.camdl. The fit was run from model.camdl (use it only if it still compiles to model 9bca0399).
The archived IR is itself a valid model argument, so the fitted model can always be replayed. A model that differs only in its quantities or contrasts runs, and says on stderr that the command line’s quantities and contrasts apply. A bare directory holding a draws.tsv outside any fit has no archived model and is not checked (it is said to run “as written”).
A model anchored to observed data (simulate { to = last_obs + 2 'weeks }, a scenario’s to, a forcing’s breakpoints) is archived by fit run with its anchors resolved against the fit’s [data], so before the comparison the command-line model is resolved the same way, against the fit’s data as it reads now. The fit’s own source therefore replays. If that data has changed since the fit, the anchors resolve to a different horizon and the replay is refused, saying so: the source is byte-identical to the fit’s archived copy (model.camdl.original), and the message names the horizon each side runs to. A refusal calls the source “edited since” only when its bytes differ from that archived copy.
A replay runs on the simulator the parameters were estimated on. The same --fit forms also fix the simulator: the fit’s forward backend, its integration step, and, on ODE, its integrator. Without a flag, every arm of --draws posterior|prior|<file> --fit (and --init-state fit) runs on them:
- backend — a stored run’s own record (its method’s
backendinrun.json); for a problem-folder handle or a fit config, the config’s[method] backend. A fit config with no[method]names no backend, and the command’s default applies. - dt — a stored fit’s recorded
fit.meta.jsondt(§2.2.1), else the dt rule over the archived model’s step and the archived config’s[config] dt; for a fit config, the same rule over the command-line model and the config, which refuses a[config] dtthat disagrees with the model’s declared step asfit rundoes. - integrator — the model’s own, which is the fit’s: the model check above makes the command-line model the fitted one, and the integrator is part of that identity.
A --backend, --dt or --integrator that differs from the fit’s still runs, with one stderr line naming both simulators:
note: results/fits/cb-3e073494/pmmh-b7a0a81d/seed_1-e6a47b86 was fitted on chain_binomial at dt = 0.5; this replay runs on chain_binomial at dt = 1 (--dt as given), so the parameters drive a different simulator from the one they were estimated on
Each flag replaces its own setting and nothing else. dt is a [config] setting every backend integrates at, so a --backend change keeps the fit’s step. The integrator is the one setting that belongs to a single backend: --integrator on a replay whose backend is not ODE is refused, since it would select nothing yet change the run’s identity. A bare directory holding a draws.tsv outside any fit has no record of a simulator, and runs on the command’s own (said on stderr, with the backend and step it used).
The store path names the simulator a cell ran on (<backend>-dt<dt>-…), so a replay lands under the fit’s backend and step. A cell stored at another backend or step keeps its own key and stays valid there; passing that --backend / --dt explicitly finds it again. compare derives a fit’s prequential on the same reading: its filter runs at the fit’s dt.
--draws-out PATH writes the resolved rows back out in the same one-row-per-draw, one-column-per-parameter format --draws PATH reads, so a generated cloud round-trips.
3.5 Seeds — the S layer
rust/crates/cli/src/sim_job.rs
#[derive(Debug, Clone)]
pub enum Seeds {
/// A single base seed; replicates derive seeds via XOR-mixing.
Single(u64),
/// An explicit list. Each seed is a seed-slot used verbatim —
/// "seed N means the same trajectory."
Explicit(Vec<u64>),
}The load-bearing distinction is whether the seeds were given explicitly. Seeds::explicit() returns Some(&[..]) only for the list form, and the engine branches on it: an explicit list indexes directly by replicate, so seed N names the same trajectory in every run that mentions it; a single base seed derives per-cell seeds by XOR-mixing the point and replicate indices (§3.1). With an explicit list, the list length is the replicate count — the ParamSource’s own replicates is not consulted.
Surfaces:
| Surface | Result |
|---|---|
simulate --seed 42 |
Seeds::Single(42) (default 1) |
simulate --seeds 1:100 |
Seeds::Explicit([1..=100]) |
simulate --seeds 1,2,42 |
Seeds::Explicit([1, 2, 42]) |
--seed and --seeds conflict, as do --seeds and --replicates.
3.6 ScenarioRef — the σ layer
rust/crates/cli/src/sim_job.rs
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum ScenarioRef {
/// Reference a scenario defined in the `.camdl` file.
Named(String),
/// Inline definition (a `[[scenario]]` entry carrying patches).
Inline {
name: String,
#[serde(default)] enable: Vec<String>,
#[serde(default)] disable: Vec<String>,
#[serde(default)] params: IndexMap<String, f64>,
},
}A reference is resolved against the model’s scenarios{} presets before it can be run, and resolution is total — there is no “maybe it is a preset” state downstream:
pub enum ResolvedScenario {
/// A `Named` ref to a declared preset; the preset is the source of truth
/// for enable/disable/set/scale/compose.
Preset { name: String },
/// An `Inline` arm built from flags; its patch (possibly empty) applies.
Adhoc { name: String, enable: Vec<String>, disable: Vec<String>,
params: Vec<(String, f64)> },
}
pub fn resolve_scenario_ref(scenario: &ScenarioRef, model_preset_names: &[String])
-> Result<ResolvedScenario, String>;Resolution follows the variant, the way the engine runs it — a Named ref through the preset path, an Inline arm through the ad-hoc path:
Named, declared by the model →Preset.Named, not declared → error, listing the declared scenarios. No name is special: a model may declare a scenario calledbaseline, and--scenario baselinethen means that scenario; on a model that declares none, it is refused like any other undeclared name.Inline→Adhoc, carrying its patch. The CLI builds these for the arm a run has without--scenario:simulate’s(none)andfit predict’sfitted, each the empty patch unless--enable/--disablewere given.Inlinewhose name is a declared scenario → error. A scenario reference is either a model preset or an ad-hoc patch, never both; the model scenario is the source of truth.
When no [[scenario]] entries are defined and no --scenario flag is given, a single implicit arm, labelled (none), is used. This is not a “default scenario” — it is the absence of any scenario patch, and it hashes to the real digest of the empty delta rather than to a literal zero.
A scenario whose set or scale touches a swept parameter — directly, or through a preset it composes — is refused before any cell runs, by simulate --sweep and its --dry-run alike. Precedence alone (§1.3) would let the scenario override every sweep value, so the sweep would not vary that parameter:
error: parameter `beta` is controlled by both the sweep and scenario `baseline`
sweep: beta = [0.2, 0.3, 0.4]
scenario: set beta = 0.3
The scenario would override every sweep value, so this sweep would not vary `beta`.
Fix: remove `beta` from the sweep, or use a scenario that does not touch it.
A scale on a swept parameter is refused the same way (“The scenario would rescale every sweep point of beta; composing a sweep with a scenario that scales the same parameter is not supported”), and a value that arrives through compose is reported with the name of the composed preset it came from. A [design.*] block’s points run as a sweep and are checked the same way. A scenario on parameters the sweep does not touch composes with it by Cartesian product as usual.
3.7 From a job to a path: run identity
Output paths are never constructed ad hoc. A command resolves its inputs into typed identity values, hashes them into per-level digests, and the store derives the path from those digests. Two properties follow: the same inputs always land in the same directory (so a re-run is a cache hit), and different inputs never share a directory (so a stale result cannot be served for changed inputs).
Step 1 — resolve. rust/crates/cli/src/resolve.rs maps a cell’s already- resolved CLI inputs into runid identity values. Every field entering a hash is a concrete value: parameter and scenario maps are name→value, the seed is the resolved process_seed (never the base --seed), and a non-finite resolved float is a ResolveError raised before any hashing.
pub fn resolve_trajectory(ctx: &TrajectoryCtx) -> Result<ResolvedTrajectory, ResolveError>;
pub struct ResolvedTrajectory {
pub levels: Vec<LevelId>, // five levels, in path order
pub run_id: ContentHash, // derived from the level hashes
}Step 2 — the five levels. A forward simulation’s identity is factored into five digests, each hashing a disjoint slice of the input set (rust/crates/runid/src/inputs.rs):
| Level | Identity value | What it folds in |
|---|---|---|
model |
ModelDigest |
The whole canonical IR, plus ir_version and the engine version |
config |
SimConfig |
backend, dt, t_start, t_end, output schedule, calendar mode, allow_degenerate_rates, no_flows, columns |
params |
ResolvedParams |
The resolved base parameter map, plus content digests of any --table files |
scenario |
ResolvedScenario |
Sorted enabled/disabled intervention ids and the canonical parameter patch |
seed |
Seed |
process_seed only |
Two rules govern membership, and they are the whole discipline:
- A field that changes the stored bytes is identity and must re-key. This includes output-view flags:
--no-flowsand--columnschange which columns the leaf contains, so they ride inSimConfig.--output-everyis not there because it lowers into the model’s output schedule and therefore rides the model digest instead. - A re-encoding of the same values is presentation and is stripped.
output.formatandsimulation.time_semanticsare blanked byinputs::normalize_for_hashinsideModelDigest::from_model— the one constructor every identity path uses, so no artifact kind can silently opt out.
Fields marked #[run_input(provenance)] are recorded but never hashed: Seed::base_seed and ArtifactRef::kind are the two on the simulate path. Everything else in a #[derive(RunInput)] type is folded, in declaration order, with no skip-if-default — adding a field re-keys every existing leaf of that kind, which is deliberate versioned turnover, scoped by bumping either that type’s #[run_input(schema_version = N)], the crate-wide HASH_VERSION, or ir/VERSION.
Step 3 — the leaf address. runid::run_id folds the artifact kind’s declaration index and the count-prefixed level-hash sequence (rust/crates/runid/src/kind.rs), so two kinds with a coincidentally equal level sequence cannot alias.
pub fn run_id(kind: ArtifactKind, levels: &[ContentHash]) -> ContentHash;Step 4 — the path. runid::store_path (rust/crates/runid/src/layout.rs) renders one {label}-{hash8} segment per level under the kind’s partition directory. The label is provenance — a rename yields a new directory and a harmless cache miss — and the 8 hex characters are identity. Navigation and display read run.json, never the segments.
pub fn store_path(root: &Path, kind: ArtifactKind, levels: &[LevelId]) -> PathBuf;The output root resolves as CLI --output-dir > config-file output_dir > CAMDL_OUTPUT_DIR > results/ (rust/crates/cli/src/run_paths.rs, output_root). Project-specific config sits above ambient shell state deliberately: setting the environment variable must not silently redirect a fit that declared its own output_dir.
A real single-run leaf:
results/sims/
sir_basic-cd37d79d/ # model: IR digest + ir_version + engine
chain_binomial-dt1-9fe90ef5/ # config: backend, dt, schedule, output view
base-b912ced1/ # params: resolved base values (+ table digests)
_none_-e93c95fd/ # scenario: the (here empty) delta
seed_7-d0aef62a/ # seed: the resolved process_seed
traj.tsv
run.json
The params label is base for an unswept run and a key-sorted k=v join for a sweep point or draw row, collapsing to the tag draws when the join would overflow a path component.
Step 5 — write. Every CAS write goes through one seam, resolve::begin_resolved_write, which derives the path from the resolved identity, builds the RunRecord in exactly one place, and dispatches to either an atomic commit (the whole artifact set handed over at once — the simulate path) or a streaming claim that the caller writes into and finalizes (the fit path). Cache validity is FsCasStore::lookup, which requires an identity match, a current schema, status = Completed, and an exact-set file check; anything else is a Miss, a Stale(reason), or a Collision on a short-hash-prefix clash, which is disambiguated rather than overwritten.
Derived ensembles. A multi-cell simulate also writes the combined wide-format TSV as its own artifact kind, SimEnsemble, under results/ensembles/ with four levels — model / config / params / grid (rust/crates/cli/src/sim_ensemble_cas.rs). The grid level folds the sorted per-cell list and the explicit cell count, so three replicates and four replicates are different ensembles; the ensemble’s deps carry one ArtifactRef per cell leaf, pinned to that cell’s traj.tsv digest.
4. Single-Run Simulation
The .camdl file plus a parameter TOML is a complete, self-contained specification for running a model. No fit config is needed for exploration.
4.1 Basic CLI
# Baseline (no scenario) — writes a leaf in the content-addressed store
# and prints where it landed on stderr (§4.4)
camdl simulate seir_vaccine.camdl --params params.toml --seed 42 stored ./results/sims/seir_vaccine-a0c10f3e/chain_binomial-dt1-e669f2d2/base-eb8ec4ef/_none_-e93c95fd/seed_42-dd2fb524
camdl cat 18bd7a09b098c20c22fd478abd331b5475d213c57f5505ead75088f35a523c27
The four hex suffixes and the run_id are content hashes; they change whenever any identity-bearing input changes (including the runtime version), so the exact digests in this document will not match your run.
# Named scenario from the model's `scenarios {}` block
camdl simulate seir_vaccine.camdl --params params.toml --scenario with_sia --seed 42
# Ad-hoc intervention toggle (no named scenario)
camdl simulate seir_vaccine.camdl --params params.toml --enable sia_round_1 --seed 42
# Parameter override (M layer)
camdl simulate seir_vaccine.camdl --params params.toml --param beta=0.5 --seed 42
# Scenario + parameter override (σ layer + M layer); valid because `with_sia`
# sets no parameter — a `--param` on a parameter the scenario sets is refused
camdl simulate seir_vaccine.camdl --params params.toml --scenario with_sia --param beta=0.5 --seed 42
# Resolve everything and print the plan without simulating
camdl simulate seir_vaccine.camdl --params params.toml --seed 42 --dry-run--dry-run prints the resolved plan and each parameter’s provenance:
camdl simulate (dry run)
model: seir_vaccine.camdl
backend: chain_binomial
dt: 1
seed: 42
scenario: (none)
Parameters (7):
I0 = 10 params.toml
N0 = 100000 params.toml
beta = 0.300000 params.toml
gamma = 0.100000 params.toml
omega = 0.003000 params.toml
sigma = 0.200000 params.toml
vacc_frac = 0.800000 params.toml
--seed defaults to 1 (env CAMDL_SEED); a run without an explicit seed is still deterministic and still content-addressed.
4.2 CLI Flag Rules
--scenario and --enable/--disable are mutually exclusive (both σ layer). clap rejects the combination at parse time, before the model is compiled:
camdl simulate seir_vaccine.camdl --params params.toml --scenario with_sia --enable sia_round_1error: the argument '--scenario <SCENARIOS>' cannot be used with '--enable <ENABLE>'
Usage: camdl simulate --params <FILE> --scenario <SCENARIOS> <MODEL>
For more information, try '--help'.
To combine a preset with an extra toggle, define a composed scenario in the model file.
--param composes with a scenario that does not set or scale the same parameter (M layer beside σ layer); on a parameter a selected scenario sets or scales it is refused (§1.3, gh#571):
camdl simulate seir_vaccine.camdl --params params.toml --param beta=0.5 --seed 42
camdl simulate seir_vaccine.camdl --params params.toml --scenario with_sia --param beta=0.5 --seed 42Other exclusions enforced at parse time:
| combination | why |
|---|---|
--seed × --seeds |
one base seed vs an explicit seed list |
--seeds × --replicates |
explicit seeds vs derived seeds |
--stdout × --seeds / --replicates / --draws / --sweep / --sweep-file |
--stdout is single-cell only |
--stdout × -o / --obs* |
those mirror the store --stdout opts out of |
--event-log × --seeds / --replicates / --draws / --sweep / --sweep-file |
event-log recording is single-run only |
--draws-out without --draws |
it writes the rows --draws generated |
--obs × --obs-dir / --obs-only; --obs-dir × --obs-only; --obs-only × --obs-only-dir; --obs-only / --obs-only-dir × -o |
one observation output form at a time |
The --obs* combinations are clap conflicts like the rest (§3.1.1), rejected at parse time. Two checks need the compiled model and run after it, exiting 1 with a one-line message: --fit is legal without --draws only when something needs the fit’s data rather than its posterior — an anchored --to or an anchored model — and is refused otherwise (--fit requires --draws (or an anchored --to, or an anchored model)). --init-state fit is a separate check: it needs --draws posterior --fit <fit results dir>.
Precedence of parameter sources (last wins). Compiled-in values (§1.3 tier 1, normally none) → --params files in order → draw row or sweep point → scenario set/scale (preset or inline) → --param. A scenario that sets a parameter therefore overrides a value coming from generated --draws. --param outranks a scenario only on parameters the scenario leaves alone; a --param on a parameter a selected scenario sets or scales is refused (§1.3). A scenario touching a parameter that a --draws <file> column or a --sweep varies is refused rather than resolved (§3.2). See §5.4.
4.3 Parameter and Model Inputs
--params FILE # load a parameter TOML (may be repeated; applied in order)
--param NAME=VALUE # override a single parameter (may be repeated)
--param-vec PREFIX=FILE # override an indexed parameter family from a file
--table NAME=FILE # supply the data for a table declared `external("NAME")`A model declaring C : patch × patch = external("contact") requires the matching --table:
camdl simulate sir_ext_table.camdl --scenario baseline --seed 1error: table 'contact' is declared as external() but --table contact=<file> was not provided
camdl simulate sir_ext_table.camdl --scenario baseline --seed 1 --table contact=contact.tsvThe table file is a headerless numeric TSV whose shape matches the declared dimensions; the file’s bytes enter the run identity, so editing it produces a new leaf.
Backend and integration knobs.
--backend {gillespie|chain_binomial|ode} # default chain_binomial
--dt N # step size for discrete-time backends (default 1.0)
--integrator {rk4|rk45} # ODE method override; tolerances live in the modelTrajectory column view. These change the emitted columns and the run identity, so a filtered run is a distinct leaf:
--output-every N # one row every N time-units, overriding the model's `output { every }`
--no-flows # drop every `flow_*` column
--columns S,I # allow-list of output columns; emitted order follows the model
--dates # add a calendar `date` column (requires the model to declare `origin`)camdl simulate seir_vaccine.camdl --params params.toml --seed 42 \
--output-every 30 --columns S,I --stdout# 0.1.0+3e2b2888 (2026-08-10)
t t_start t_stop S I
0 0 0 99990 10
30 0 30 99791 64
60 30 60 96434 1208
Without an origin in the model, --dates is an error:
error: --dates requires the model to declare an `origin` (e.g. `origin = date("2020-01-01")`).
4.4 Output destinations — the content-addressed store by default
Every run writes a leaf into the content-addressed store under --output-dir (default ./results, env CAMDL_OUTPUT_DIR) and reports the leaf path plus the camdl cat that reads it back. Both lines go to stderr, so a --stdout pipeline stays clean. A --cas flag is accepted and ignored — content addressing is not opt-in.
# Default: write the leaf, print where it went.
camdl simulate seir_vaccine.camdl --params params.toml --seed 42
# stored ./results/sims/…/seed_42-dd2fb524
# camdl cat 18bd7a09…
# Opt out: stream the trajectory to stdout, write no leaf.
camdl simulate seir_vaccine.camdl --params params.toml --seed 42 --stdout > out.tsv
camdl simulate seir_vaccine.camdl --params params.toml --seed 42 --stdout | head
# Write a plain-TSV mirror AND the leaf (the file is a convenience copy).
camdl simulate seir_vaccine.camdl --params params.toml --seed 42 -o out.tsv
# trajectory written to out.tsv
# stored ./results/sims/…
# Browse what you have run.
camdl list
camdl show <run-id-prefix>
camdl cat <run-id-prefix>The three destinations. A run always produces exactly one primary trajectory artifact; these say where it goes.
| flag | trajectory | store leaf written? |
|---|---|---|
| (default) | store leaf only | yes |
-o FILE / --output FILE |
store leaf and FILE |
yes |
--stdout |
stdout only | no |
The trajectory is never written to stdout without --stdout, at any size. Commands whose primary result is a scalar do echo it: camdl pfilter prints the log-likelihood to stdout while also writing its own leaf, so the tight “vary θ, re-check” loop stays a one-liner:
camdl pfilter seir_observations.camdl --scenario baseline \
--data weekly_cases=obs/weekly_cases.tsv --data detection=obs/detection.tsv \
--particles 200 --seed 1 2>/dev/null-190.8639
Mirror files carry a provenance header. --stdout and -o both prefix the TSV with a comment line naming the binary that produced it:
# 0.1.0+3e2b2888 (2026-08-10)
t t_start t_stop S E I R V flow_infection flow_progression flow_recovery flow_waning
The bytes in the store leaf’s traj.tsv are the same table without that header.
Re-running. A lone camdl simulate re-simulates its cell on every invocation and re-commits the identical leaf; the commit is idempotent, so the store does not grow. A multi-cell run skips the cells the store already holds and reads their traj.tsv back for the combined outputs, reporting N of M cells read from the store; simulated K. A cell is held only when every requested artifact is present: the leaf’s own files and, when observations are requested, the exact obs/<hash8>-<obs_seed>/ subtree with every stream file and its obs.json — a leaf holding another cadence’s subtree is a miss. A leaf is found by its hashes, not its label, so a renamed model file or a relabelled arm is still a hit. --force recomputes every cell (the displaced leaves are quarantined, not deleted); --quantities-out recomputes every cell too, since the quantities are a sidecar over the in-memory trajectory. --dry-run on a multi-cell run prints every cell as [hit ] or [miss] with its leaf path and the counts, and writes nothing.
Layout. Five factored levels under <output-dir>/sims/:
sims/{model}-{h8}/{config}-{h8}/{params}-{h8}/{scenario}-{h8}/seed_{n}-{h8}/
run.json # identity, levels, artifact digests, output schema, provenance
traj.tsv # the trajectory
obs/ # present only when synthetic observations were requested
Each component is <human label>-<first 8 hex of that level's hash>. The label is display only; identity is the hash. A params level with no overrides is labelled base; a full drawn parameter vector collapses to draws so the path component stays under NAME_MAX.
Hash composition. The model level keys on the compiled IR; config on backend, dt, integrator, and the column view; params on the resolved base parameters, draw/sweep overrides, and the bytes of any --table file; scenario on the resolved enable/disable/set delta; seed on the process seed. The runtime version participates, so a code change that alters simulation semantics produces new leaves rather than silently reusing old ones.
--no-flows / --columns re-key the config level only. --output-every rewrites the compiled IR’s output schedule, so it re-keys the model level as well — same model source, different model hash. --emit-every re-keys neither: it touches only emitted observations, so it keys the leaf’s obs/ child instead (§10.5).
Ensembles. --seeds, --replicates, and --draws write one leaf per cell plus a single combined SimEnsemble artifact under <output-dir>/ensembles/:
camdl simulate seir_vaccine.camdl --params params.toml --scenario baseline,with_sia --seeds 1:32 scenarios × 3 replicates = 6 runs
storing ensemble · 6 cells (276.2KB)
ensemble ./results/ensembles/seir_vaccine-a0c10f3e/chain_binomial-dt1-72322740/base-2c2d41e8/cells-n6-70131f1a
stored 6 leaves · ./results/sims/
camdl list
camdl cat on the ensemble emits the combined wide-format TSV, with leading index columns gated on the grid shape — replicate always, then scenario when more than one scenario ran, then draw under --draws, or point_id, one sweep:<param> column per swept parameter and seed under --sweep / --sweep-file:
# 0.1.0+3e2b2888 (2026-08-10)
replicate scenario t t_start t_stop S E I R V flow_infection flow_progression flow_recovery flow_waning
0 baseline 0 0 0 99990 0 10 0 0 0 0 0 0
replicate is a 0-based cell index, not the seed. With --seeds 10,20 the column reads 0 and 1; the seed that produced a row is recorded in the per-cell leaf’s run.json, not in the combined TSV — except under a sweep, whose seed column is the pairing key across design points. draw is the 0-based draw index.
--parallel N (env CAMDL_PARALLEL) runs the simulation phase of a multi-cell simulate on N Rayon threads (0 = every core; absent = sequential). The combined files, the observation tables and the stored ensemble are byte-identical whatever the value: a cell’s seed is a function of its grid position, and the engine merges cells in canonical order.
4.5 camdl list / show / cat / label — the primary access path
Because output goes to the store by default, these are the way you reach results. list defaults to ./results (positional ROOT, --root DIR, or CAMDL_OUTPUT_DIR); every run’s banner hands you the exact camdl cat to run next.
# Tabular overview, most recent first, one section per artifact kind
camdl list
camdl list --since 1h # last hour
camdl list --model seir_vaccine # substring match on the model path
camdl list --scenario baseline --all
camdl list --kind sim # sim | fit | profile | pfilter | survey | ensemble | all
camdl list --format json # machine-readable; see the caveat below
# Full metadata for one run (short-hash prefix resolves git-style)
camdl show 18bd7a09
camdl show ./results/sims/seir_vaccine-a0c10f3e/chain_binomial-dt1-e669f2d2/base-eb8ec4ef/_none_-e93c95fd/seed_42-dd2fb524
# Emit the trajectory, or a named stream from the leaf
camdl cat 18bd7a09
camdl cat b1fb4eb0 --stream cases_urban
camdl cat <id> --stream event_log.tsv
# Attach a display label to any run
camdl label 18bd7a09 "reference run"camdl list groups by artifact kind and shows:
sims
CREATED RUN_ID LABEL MODEL SCENARIO SEED PARAMS SIZE PATH
just now 52f1a1ba <unlabelled> seir_vaccine (none) 42 base 35K ./results/sims/seir_vaccine-a0c10f3e/…/seed_42-dd2fb524
13m ago 18bd7a09 reference run seir_vaccine (none) 42 base 32K ./results/sims/seir_vaccine-a0c10f3e/…/seed_42-dd2fb524
with a separate ensembles block carrying CELLS instead of SCENARIO/SEED. --limit defaults to 50; --all removes the cap.
An ensemble row stands for its member leaves. A multi-cell simulate stores one sim leaf per cell and one ensemble leaf that lists them as dependencies; in any view that includes ensembles, the per-cell sim leaves the listed ensembles reference are not printed as rows of their own (membership is decided over every ensemble in the store, before --limit/--since truncate). A sims block therefore shows lone runs and cells that belong to no stored ensemble. --kind sim excludes ensembles and so lists every per-cell leaf; the cells are always reachable by camdl show on their run_id.
In --format json a fit row’s methods[] holds one entry per run, each with its own run_id, label and created_at; the row-level label is a display string, every run’s label joined with ", ", and names no run (gh#896).
Paths are printed relative to the root you asked for, so they are copy-paste ready into camdl show / camdl cat. Short-hash prefix resolution is git-style:
camdl show 1error: '1' is ambiguous, matches 2 entries:
sim ./results/sims/sir_priors-39588ff2/…/seed_5871781006564002450-1277c5c6
sim ./results/sims/seir_vaccine-a0c10f3e/…/seed_42-dd2fb524
refine by appending /<scenario> and/or /<seed_N>, or pass a longer hash prefix
camdl show prints the resolved path, kind, model, scenario, seed, config, the full run_id, every level with its hash, artifact sizes, creation time, engine version, and the argv that produced the run.
--format json emits the sim, fit, profile, pfilter, and ensemble sections as JSON Lines (one object per line) and the survey section as a pretty-printed JSON array. The stream is therefore not a single JSON document; parse it per section or filter with --kind.
A fit row carries schema = {"name": "fit_list_row", "version": 2}, the fit-level provenance (its source_config and source_model are fit.meta.json’s, relative to the fit folder, §2.2.1), methods_declared (the algorithms that ran on this problem), and methods — one entry per method leaf:
{"method":"pgas","method_hash":"0727792d…","run_id":"b99e1710…",
"path":"./results/fits/pgas-50e0eb11/pgas-0727792d/seed_1-e6a47b86",
"backend":"chain_binomial","seed":1,"n_chains":2,
"best_loglik":-35405.944659607965,"loglik_type":"complete_data","best_chain":1}This is a completed two-chain PGAS leaf. best_loglik and best_chain are the leaf’s run.json inputs.best_loglik / inputs.best_chain, printed as recorded: best_chain is the chain number (a chain_N/ directory), and best_loglik is the value loglik_type names — for PGAS the highest complete-data log-likelihood of any sweep, not a marginal likelihood, so it does not compare with an IF2 or pfilter row. Either field is null only when the method recorded none (best_chain for pfilter, which runs replicates rather than competing chains; best_loglik when the value was not finite).
method alone does not identify a leaf. One segment can hold several leaves of the same algorithm — the same problem fitted at two sweeps settings, say — and they agree on method, backend and seed; the method level’s hash is what separates them, and it is the <h8> in each leaf’s directory name. run_id and path are the addresses camdl show and fit predict accept.
<root>/index.json is a derived run_id → leaf index, rebuilt lazily on a prefix-resolution miss and by camdl dev reindex <root>. Deleting it is safe.
4.6 Synthetic Observations
# All streams into one wide TSV (single-cadence models only)
camdl simulate sir_two_patch_long_obs.camdl --scenario baseline --seed 42 --obs cases.tsv
# One TSV per observation stream (multi-stream / mixed-cadence models)
camdl simulate seir_observations.camdl --scenario baseline --seed 42 --obs-dir obs/
# Emit weekly instead of the model's declared cadence — every stream, then one
# stream by its observation-block label (gh#656, §10.5).
camdl simulate seir_observations.camdl --seed 42 --emit-every 7 --obs-dir weekly/
camdl simulate seir_observations.camdl --seed 42 --emit-every cases=7 --obs-dir mixed/
# Independent replicates
camdl simulate sir_two_patch_long_obs.camdl --scenario baseline --seed 42 \
--replicates 5 --obs cases_rep.tsv
# Suppress the loose trajectory mirror, emit observations only (SBC workflows)
camdl simulate seir_observations.camdl --scenario baseline --seed 42 --obs-only-dir obs_only/--obs writes one wide table with a column per stream:
time cases_urban cases_rural
0 0 0
7 2 3
14 1 0
Under --replicates/--seeds/--draws the table gains a leading replicate column (0-based, as in §10.2):
replicate time cases_urban cases_rural
0 7 2 3
--obs refuses a model whose streams emit on different schedules, before simulating:
error: observation streams have different schedules (weekly_cases: …step: 7.0…, detection: …step: 14.0…).
A single wide TSV cannot hold multi-cadence streams.
Use --obs-dir (one file per stream, keeps trajectory) or
--obs-only-dir (one file per stream, suppresses trajectory).
--obs-dir / --obs-only-dir write one file per stream under the columns the stream declared, so the file re-loads under its own model (gh#833): the value under the scored column’s name, the time under the : time column’s name — or both boundaries under the window_start/window_stop names for a stream that declares its windows per row, each with a date_* twin under --dates:
win_start win_stop cases
0 7 2
7 14 3
Each row is drawn over the period the stream’s covers assigns to its label, and a row whose period falls outside the run is not written. Under closing_at with a schedule starting at t_start, the row labelled t_start is not written: its period [t_start − Δ, t_start) was never simulated. A windowed stream cannot go into the single wide --obs file, which has one time column; the error names --obs-dir.
A stratified family (cases[p in patch]) is one file, not one per leaf. Its leaves share one observation source, which the loader binds to a single long-format file and routes row by row to the leaf named in the : dim column — so the family is written that way, named for the source, with the dim column filled from each leaf’s own stratum (gh#884):
week_ending province province_cases
7 kivu 61
7 ituri 17
14 kivu 111
--design-from: the rows a fit actually scores
--design-from <fit.toml> replaces the emit schedule as the source of a stream’s rows. That config’s [data.observations] is loaded exactly as fit run loads it, and each stream is written on the rows the loader bound: its observed labels, each row’s own period — per-row window_start/ window_stop widths included — and each NA hole, written as a row with its period and no value. Only the values are simulated.
This is what a simulation-based self-consistency test needs (gh#831). Simulating on a regular grid gives the synthetic fit more information than the real one has: a three-day row becomes three one-day rows, and a hole becomes an observation.
The temporal columns are written in the representation the bound file used. A stream whose cells were ISO dates is re-emitted as ISO dates, rendered through the model’s origin and time_unit; one whose cells were numbers keeps the numbers. So the synthetic replicate is the same shape as the file it stands in for, column names and cells alike, and pairs with it directly.
camdl simulate model.camdl --params theta.toml \
--design-from fit.toml --obs-only-dir synth/
It requires --obs-dir, --obs-only-dir or --projected-out (one file per stream), and takes θ from --params/--param — one dataset, written into the directory — or from --draws, which writes one ds_NN/ subdirectory per draw so each dataset stays a file set the loader can bind on its own. What it produces is a dataset, not a run: no trajectory and no store leaf are written, with --obs-dir as with --obs-only-dir. It conflicts with the flags the store-backed trajectory pipeline owns (--obs, --obs-only, -o, --stdout, --seeds, --replicates, --emit-every, --dates, --init-state, --quantities-out, --event-log), and with --scenario, since the dataset is written from the parameter vector alone with no scenario applied; so nothing passed is silently dropped. To simulate a dataset under a scenario’s parameter values, give the values through --params.
A stream whose likelihood reads a data column — a binomial denominator n = tested, a person-time offset — is refused by name: there is no data file to read that column from when the data is what is being generated, and writing 0 would assert an observation the run never made (gh#829). A stratified (long-form) stream is refused for the same reason in a different shape: its family shares one file per source, which one file per stream would not re-load.
Projections on the design: --projected-out DIR. With --design-from, --projected-out DIR writes each stream’s projected value on the same rows: the model quantity the stream’s projected = … expression defines over each row’s period, before any observation noise. One file per stream holds the stream’s temporal column(s) and a column named projected, at full round-trip precision. A hole in the data still gets its value, because the projection exists whether or not the row was observed. It may be passed with or without --obs-dir / --obs-only-dir, and with --draws it writes one ds_NN/ subdirectory per draw.
camdl simulate model.camdl --draws perturbations.tsv \
--design-from fit.toml --projected-out projections/
A projection reads no likelihood argument, so the data-column refusal above (gh#829) does not apply: a stream whose dispersion or denominator comes from a data file is projected like any other. Nor do the round-trip refusals, because the file is not a dataset: its column is projected, not the stream’s observed name, so it cannot be bound back as data by accident. The value is the projection itself; a likelihood may transform it (mean = 0.86 * projected). Projections are what a sensitivity or identifiability analysis differentiates, and what a posterior-predictive mean averages. Like --quantities-out, the output is a sidecar outside run identity.
--obs-only / --obs-only-dir suppress only the loose trajectory mirror; the store leaf still holds traj.tsv. In every mode the sampled observations are also written into the leaf under obs/<obs-hash>-<obs-seed>/<stream>.tsv alongside an obs.json, so they are reachable with camdl cat <id> --stream <name> without keeping the loose files.
These writers — --obs, --obs-dir, the store’s obs/ subtree, and a generated quantity that reduces observations.<stream> — draw every stream from the model alone, with no data columns. So a stream whose likelihood reads a data column is refused by name before any cell runs, including a ratio stream’s n that --design-from writes from the model: a missing column would otherwise be drawn as 0 (gh#829). A model with such a stream emits none of its streams this way; --design-from draws only the streams the fit binds.
The observation RNG is seeded independently of the process RNG, so adding --obs does not change the trajectory.
5. Ensembles and Sweeps
One engine behind every grid. camdl simulate covers a grid expressed entirely in flags: scenarios × parameter points × replicates or seeds, where the parameter points are a sample (--draws) or a design (--sweep / --sweep-file, §5.5). It builds a SimulateJob, hands it to engine::run_job, and writes one five-level sim leaf per cell, so camdl list / show / cat browse every cell uniformly; a multi-cell run also writes one SimEnsemble leaf holding the combined table (§4.4). A grid the store already holds is read back, not re-simulated.
5.1 CLI Invocations
# ── Multiple scenarios × seeds ───────────────────────────
camdl simulate seir_vaccine.camdl --params params.toml \
--scenario baseline,with_sia --seeds 1:1000
# --scenario may also be repeated instead of comma-separated:
camdl simulate seir_vaccine.camdl --params params.toml \
--scenario baseline --scenario with_sia --seeds 1:1000
# ── Stochastic replicates at one parameter point ─────────
camdl simulate seir_vaccine.camdl --params params.toml --replicates 100
# ── Posterior predictive from a draws file ───────────────
camdl simulate sir_priors.camdl \
--draws draws.tsv --replicates 10 --obs ppc.tsv
# ── Prior predictive (model must declare `~ prior(...)`) ─
camdl simulate sir_priors.camdl --draws prior -n 500 --replicates 5 --obs-dir prior_pred/
# ── Uniform space-filling over declared bounds ───────────
camdl simulate sir_priors.camdl --draws uniform -n 500 --replicates 5 --obs-dir uniform_pred/
# ── Draws from a completed fit's posterior cloud ─────────
camdl simulate sir_priors.camdl --draws posterior --fit results/fits/sir-8a3f12b4 --replicates 10
# ── Record the sampled parameter vectors ─────────────────
camdl simulate sir_priors.camdl --draws uniform -n 5 --draws-out draws_uniform.tsv
# ── Deterministic grid over a parameter (the design axis) ─
camdl simulate sirv.camdl --params params.toml \
--scenario baseline,with_vaccination --sweep beta=lin(0.2,0.6,5) --seeds 1:50
# ── Two swept parameters: the name-sorted Cartesian product ─
camdl simulate sir_basic.camdl --params params.toml \
--sweep beta=0.2,0.3 --sweep gamma=0.1,0.2 --replicates 10
# ── Design points from a file (one row per point) ─────────
camdl simulate sir_basic.camdl --params params.toml --sweep-file design.tsv --replicates 5
# ── Plan the grid against the store without running ───────
camdl simulate sirv.camdl --params params.toml --sweep beta=lin(0.2,0.6,5) --seeds 1:50 --dry-run-n / --n-draws sets the draw count for --draws prior|uniform (there is no --n). --fit is required for --draws posterior, optional for --draws prior (a fit.toml supplying priors) and for --draws <file> (a [fixed] block supplying the parameters the file’s columns omit).
--draws prior samples only the parameters that declare a prior. A parameter with no prior that nothing else sets — no compiled-in value, --params, scenario, or --param — is refused before any cell runs, naming the scenario that leaves it unset:
error: parameter 'c' has no prior, and nothing gives it a value for scenario 'other'.
--draws prior samples only the parameters that have a prior; every other parameter takes its value from --params FILE, the scenario's `set`, or --param NAME=VALUE.
Fix options: declare a prior on 'c' in the model's parameters {} block (`c : <type> in [lo, hi] ~ <dist>(...)`), supply `--fit FIT.toml`, set it with --params FILE or --param c=VALUE, or set it in scenario 'other'.
A prior whose mass falls partly outside the declared bounds is rejection-sampled and reported:
warning: prior for 'gamma' placed 16.7% mass outside declared bounds (1 rejected / 5 accepted). Consider widening bounds or tightening the prior.
generated 5 prior draws from model IR (7 sampled params; 0 other parameter(s) resolve per cell from --params, the scenario's set or --param)
Combining an explicit draws file with a scenario that sets or scales one of its columns is refused, because the scenario would override that column in every draw:
error: parameter `mu` is controlled by both the draws file `draws.tsv` and scenario `pin_mu`
draws file: mu = [0.3, 0.4]
scenario: set mu = 0.3
The scenario would override the file's `mu` in every draw, so that column would have no effect.
Fix: drop the `mu` column from the draws file, or use a scenario that does not touch it.
The same collision with generated draws (--draws prior|uniform|posterior) is not an error: the scenario wins for that parameter, so every draw resolves to the same value of it, and the replacement is reported on stderr (§1.3, §5.4).
5.2 CLI ↔︎ Type Mapping
camdl simulate parses into args::SimulateArgs (clap) and lowers to sim_job::SimulateJob; fit predict builds the same job for its free-forward replay. From there neither the engine nor the store knows which verb produced the job.
/// The resolved engine input.
pub struct SimulateJob {
pub model: String, // resolved IR or .camdl path
pub params_files: Vec<String>, // --params, applied in order
pub backend: ForwardBackend, // gillespie | chain_binomial | ode
pub dt: f64,
pub integrator: Option<IntegratorArg>, // --integrator rk4|rk45
pub source: ParamSource, // where parameter vectors come from
pub scenarios: Vec<ScenarioRef>, // σ layer; empty ⇒ the implicit `(none)` arm
pub t_end_override: Option<f64>, // --to, resolved to model time
pub required_output_times: Vec<f64>, // extra snapshot times (fit predict)
pub init_state: Option<Arc<InitStateSource>>, // --init-state ensemble
pub obs_anchors: Option<ObsAnchorTimes>, // resolved first_obs/last_obs, once per job
pub seeds: Seeds, // S layer
pub cli_overrides: Vec<(String, f64)>, // --param (highest M tier)
pub fit_fixed: IndexMap<String, f64>, // --draws prior|<file> --fit: the [fixed] block
pub set_vec_entries: Vec<(String, String)>, // --param-vec
pub table_files: Vec<(String, String)>, // --table
pub obs: ObsOutput,
pub parallel: usize, // rayon threads; --parallel (absent 1, 0 = all cores)
}
pub enum ParamSource {
/// One point, run `replicates` times. `simulate` with no --draws.
Point { replicates: usize },
/// Design points: --sweep (name-sorted Cartesian product) or --sweep-file rows.
Sweep { points: Vec<SweepPoint>, replicates: usize },
/// Pre-resolved draws. `explicit_file` is Some only for --draws <file.tsv>,
/// which is what gates the scenario-collision error (§5.1).
Draws {
rows: Vec<IndexMap<String, f64>>,
replicates: usize,
explicit_file: Option<PathBuf>,
},
}
pub enum ScenarioRef {
/// A `scenarios {}` preset in the model file.
Named(String),
/// An inline patch (the ad-hoc arm: --enable/--disable/--param).
Inline { name: String, enable: Vec<String>, disable: Vec<String>,
params: IndexMap<String, f64> },
}
pub enum Seeds {
/// One base seed; replicates derive seeds by XOR-mixing.
Single(u64),
/// An explicit list (`--seeds`), used verbatim.
Explicit(Vec<u64>),
}
pub enum ObsOutput { None, File(PathBuf), Dir(PathBuf), OnlyFile(PathBuf), OnlyDir(PathBuf) }--seeds accepts an inclusive range 1:100 or a comma list 1,2,42. --scenario accepts a comma-separated list or repetition; both expand to the same Vec<ScenarioRef>.
Output shape is supplied by the verb as an engine::RunSink: simulate composes the store sink with a combined-TSV mirror sink.
5.3 Total Runs Calculation
Total runs = |param_points| × |scenarios| × |seed slots|
where |param_points| =
Point: 1
Sweep: the number of design points (the name-sorted Cartesian product of
the --sweep grids, or the rows of --sweep-file)
Draws: number of draw rows
and |seed slots| =
--seeds given: the explicit seed count
otherwise: `replicates` (default 1), seeds derived from --seed
and |scenarios| =
none specified: 1 (the implicit arm, `(none)`)
otherwise: the number of --scenario names
--seeds and --replicates are mutually exclusive; with --seeds the replicate count tracks the seed-list length.
5.4 Scenario × Parameter-Point Interaction
Scenarios and parameter points are orthogonal axes: their Cartesian product is the run grid, and each (point, scenario) pair is one cell.
They are not independent in value resolution. A scenario’s set/scale sits at a higher precedence tier than the draw/sweep point (§4.2), so:
A parameter that a scenario
sets takes the scenario’s value, discarding the sweep point or draw row for that parameter.
This matters when a preset that pins parameters is selected — the implicit arm of a run with no --scenario applies no preset, but --scenario baseline on a model whose baseline preset sets parameters applies those values over every draw. The replacement is total for the parameter but reported (§1.3): one info: line per replaced parameter, with the range of discarded values.
info: scenario `lockdown` replaced I0 = 77.09 to 875.81 (from the draw or sweep point) with 3, across 3 cells
The effect on the output is easy to miss without that line:
# sir_basic.camdl's `baseline` preset sets beta, gamma, N0, I0.
camdl simulate sir_basic.camdl --scenario baseline --draws uniform -n 3 --backend odeproduces three leaves with three distinct run_ids and three byte-identical trajectories. Dropping --scenario baseline gives three different trajectories.
For user-authored coordinates this cannot happen: a --sweep grid, a --sweep-file, or a --draws <file> whose parameters the effective scenario sets or scales is refused before any cell runs (§3.2, §3.6). Generated draws stay allowed, because a scenario over them is the intended counterfactual, so for them the rule remains practical: when drawing a parameter, the scenario in force must not set it unless discarding the draws for that parameter is what you mean. Compare two cells’ traj.tsv to check.
5.5 The design axis: --sweep and --sweep-file
A design point is a parameter value the modeller chose; a draw is a sample from a measure. simulate keeps the two apart: --draws builds the sample axis and pools its cells into a band, --sweep / --sweep-file build the design axis and keep every point distinct. The two are refused together.
--sweep PARAM=GRID, repeatable. GRID is a comma list (beta=0.2,0.3,0.6), lin(min,max,n) or log10(min,max,n); an indexed parameter is named underscore-joined (beta_north), and its column is sweep:beta_north. Several --sweep flags form the Cartesian product sorted by parameter name, the first name varying slowest; point_id is a point’s 0-based position in that product, and fit predict --sweep gives the same point_id for the same flags. A parameter named twice, or one the model does not declare, is refused before anything runs.
--sweep-file FILE: a TSV whose header names model parameters and whose rows are design points, point_id the 0-based row. A point_id column of distinct non-negative integers gives each row the id it is written under instead (in the combined and observation tables and in the ensemble record’s design.point_ids); the row position still drives nothing but the engine’s own order. A column naming no model parameter is refused with the parameter list; a file with zero rows is refused; a duplicate row is refused naming both lines; a repeated or non-integer point_id is refused naming the row. --sweep-file and --sweep are mutually exclusive. The file enters the ensemble’s record by content digest.
Seeds and common random numbers. Every design point at a given replicate shares one process seed: with --replicates 2, point 0 and point 1 of replicate 0 run on the same random stream, as do their replicate-1 cells. So a parameter the dynamics do not read gives byte-identical trajectories across points, paired differences across points are low-variance, and adding a point leaves the others’ trajectories unchanged. For independent noise per point, run one invocation per point with a distinct --seed. The pairing key is the seed column of the combined and observation tables.
Refusals. A scenario whose set or scale touches a swept parameter — directly or through a preset it composes — is refused (§3.6), on the run and the dry run alike; so is a --param on a swept parameter, since --param outranks every tier and the sweep would not vary it; so is --sweep with --draws or --design-from.
Labels and records. Each cell’s params level is labelled by the swept values, beta=0.2 in run.json and beta_0.2-<h8> in the path, two swept parameters as beta=0.3_rho=0.5; the arm label is the preset’s name, or derived from the flags for an ad-hoc arm ((none), (none)+vaccination for --enable vaccination, (none);beta=0.3 for --param beta=0.3). The ensemble leaf’s run.json carries the resolved design — its source, the point count (n_points, in place of a sample’s n_draws) and each swept parameter’s values, name-sorted — so the grid declaration lives with the results.
Columns. Under a sweep the combined ensemble.tsv (and -o mirror) leads with replicate, point_id, one sweep:<param> column per swept parameter (shortest round-trip rendering, so 0.1234567 reads back exactly) and seed, then the time columns; there is no draw column. Observation tables under --obs-dir / --obs-only-dir / --obs / --obs-only carry the same four leading columns before the stream’s own. quantities {} under a sweep are banded per (design point, scenario), never across points; one replicate per point gives a point-mode row per group.
Observations as a dataset. --obs-dir DIR writes one TSV per observation stream, stacked over every cell with the columns above; that directory is the dataset a modeller then hands to pfilter --data cases=DIR/cases.tsv or to a fit, and the same draws also land in each leaf’s obs/ subtree. There is no path-less observation flag: the destination is the product.
camdl simulate ocaml/golden/sir_basic.camdl --params ocaml/golden/sir_basic.params.toml \
--sweep beta=0.2,0.5 --replicates 2 --dry-run --output-dir results2 design points × 2 replicates = 4 runs
camdl simulate (dry run)
model: ocaml/golden/sir_basic.camdl
backend: chain_binomial
dt: 1
sweep: beta = [0.2, 0.5]
design: 2 design point(s)
seed: 1
replicates: 2
scenario: (none)
Parameters (3):
I0 = 10 ocaml/golden/sir_basic.params.toml
N0 = 1000 ocaml/golden/sir_basic.params.toml
gamma = 0.100000 ocaml/golden/sir_basic.params.toml
total: 2 design points × 2 replicates = 4 runs
store: results
[miss] sims/sir_basic-389ccfd3/chain_binomial-dt1-903a4928/beta_0.2-65985320/_none_-e93c95fd/seed_1-e6a47b86
[miss] sims/sir_basic-389ccfd3/chain_binomial-dt1-903a4928/beta_0.2-65985320/_none_-e93c95fd/seed_5871781006564002452-f483e20c
[miss] sims/sir_basic-389ccfd3/chain_binomial-dt1-903a4928/beta_0.5-ee1882a0/_none_-e93c95fd/seed_1-e6a47b86
[miss] sims/sir_basic-389ccfd3/chain_binomial-dt1-903a4928/beta_0.5-ee1882a0/_none_-e93c95fd/seed_5871781006564002452-f483e20c
0 cache hits → read back, 4 misses → would simulate
(dry run — no simulation, no files written.)
The two seed segments are shared by both beta points — the common random numbers above — and beta is absent from the parameter list because the sweep supplies it per cell.
5.6 Execution Flow
A multi-cell camdl simulate:
- Parses the flags, resolves the model to IR (compiling the
.camdlif needed), loads--params, and resolves every--scenarioagainst the model’s presets (or derives the ad-hoc arm from--enable/--disable/--param). - Expands the parameter axis — the draws of
--draws, or the design points of--sweep/--sweep-file— and builds theparam-point × scenario × seedgrid. A scenario touching a user-authored coordinate is refused here. - Classifies each cell against the store under the exact rule of §4.4.
--dry-runstops here and prints the plan, each cell as a hit or a miss with its leaf path, and the counts. - Runs the cells the store does not hold, sequentially, in canonical
scenario → point → replicateorder (each cell’s seed is derived from its coordinates, so no ordering can perturb a trajectory), and reads the held cells back. - Writes
traj.tsv+run.json(+obs/when observations were requested) per leaf, theSimEnsembleleaf with the combined table and the resolved design, and the-o/--obs*mirrors. There is no grid-level manifest file; the per-leafrun.jsonrecords are the index, with<root>/index.jsonas a derived, rebuildable lookup cache. - Reports
N of M cells read from the store; simulated Kand where the leaves landed.
6. FitConfig — the inference type
6.1 Overview
A fit.toml is one (problem, method) pair: which model to fit, what data to fit it to, which parameters to estimate versus hold fixed — the problem, which every command that takes --fit reads — and one [method], the algorithm that fits it, which only fit run reads. It defines a view of the parameter space — the partition of the model’s parameter set into free parameters (explored by the algorithm) and fixed parameters (held constant). The algorithm then operates in the reduced space of free parameters. (See Buffalo 2026 for the formal treatment of parameter views, transforms, and the downward chain from inference coordinates to simulator output.)
The runtime type is FitConfig (rust/crates/cli/src/fit/config_v2.rs), a Problem and an Inference split from one flat parse. Problem::load is the entry point for every non-fit reader (simulate --draws prior --fit, pfilter --fit, survey --fit, profile --fit) and discards the inference half, so a file with no [method] is a complete problem for them; camdl fit run — the sole entry point that executes a fit — reads both and refuses a file with no [method] by name.
Three properties govern the whole surface:
- Strict parsing. The flat wire struct and every nested config struct carry
#[serde(deny_unknown_fields)], so a misplaced or misspelled key is a load error, not a silent drop. The one exception is[fixed], whose#[serde(flatten)]map of arbitraryparam = valueentries is incompatible with the attribute. The[method]table needs a separate mechanism — see §6.9. - Paths in the file anchor at the file.
[model] camdl,output_dir,[data] file,[data.observations], and[data.holdout]are resolved against thefit.toml’s own directory at load time (anchor_paths_at,config_v2.rs), so a config is relocatable as a unit and runs the same from any working directory. Two path-bearing keys are not in that set and anchor at the process working directory instead:[fixed] from_fileand[synthetic] true_params. - Absolute paths warn.
absolute_path_warnings(config_v2.rs) emits one stderr line per absolute reference in[model] camdl,output_dir,[data] file, or[data.observations], because an absolute path pins the config to one machine’s layout. It is a warning, not an error.
6.2 Top-level keys
Every key a fit.toml accepts at the top level. Anything else fails at load with the serde list quoted in §6.9.
| key | type | required | default | meaning |
|---|---|---|---|---|
[model] |
table | yes | — | camdl = "<path>". Accepts a .camdl source or a pre-compiled .ir.json. |
[data] |
table | one of | — | Real-data source. Mutually exclusive with [synthetic]; exactly one must be present. |
[synthetic] |
table | one of | — | Generate N datasets from known truth and fit each (simulation-based calibration). |
[estimate] |
table of tables | yes | — | The free parameters. See §6.3. |
[fixed] |
table | yes | — | The held-constant parameters. See §6.4. |
[method] |
table | fit run |
— | One algorithm, its settings, and the chains’ starts. Absent, the file is a complete problem for every other reader and fit run refuses it. See §6.5. |
[config] |
table | no | { dt = 1.0 } |
Fit-wide simulator settings. See the sub-table below. |
output_dir |
string | no | results |
Output root. Anchored at the fit.toml. Not part of the fit identity. |
fit_seeds |
list of ints | no | [--seed] (CLI, or 1) |
One fit per listed seed. Duplicates rejected. |
simplex_groups |
array of tables | no | [] |
[[simplex_groups]] params = ["a","b",…] — members must form a probability simplex. Honored by IF2 only; other algorithms warn. |
scenario |
string | no | none | Named scenario from the model; applies its enable/disable lists and param overrides before inference. Exclusive with enable/disable. |
enable |
list of strings | no | [] |
Ad-hoc intervention enable list; "*" enables every toggleable intervention. |
disable |
list of strings | no | [] |
Ad-hoc disable list. Explicit disable beats always_active. |
ic_free |
bool | no | false |
Condition the likelihood on y₁ rather than on a committed initial state. Requires particles that differ in x₀: if2 always, pfilter/pmmh only when the model’s init { } declares a law (gh#732). Also requires a non-missing y₁, and — when the model’s init { } is deterministic — a perturb_only_at_t0 = true parameter. profile --fit, survey --fit and pfilter --fit score the same conditional likelihood. |
[provenance] |
table | no | none | Lineage metadata. See §6.7. |
[config] (FitBackendConfig, config_v2.rs):
| key | type | default | meaning |
|---|---|---|---|
dt |
float | the model’s dt, else 1.0 |
Integrator step. A model’s declared simulate { dt } is the step; a [config] dt that differs is refused, an agreeing one accepted. A top-level dt is a load error. |
obs_alignment |
"exact" | "snap" |
algorithm’s own | How observation times relate to the dt grid. Resolved per algorithm (§6.5); an unsupported request errors. |
allow_degenerate_rates |
bool | false |
Treat a numerical collapse in a rate expression (div-by-zero, Pow→NaN, Sqrt of a negative) as 0.0. |
[config] backend was relocated to [synthetic] backend (gh#241) and is rejected with a migration message.
[data] (DataSpec, config_v2.rs):
| key | type | meaning |
|---|---|---|
file |
string | Single wide TSV holding one column per model-declared observation stream. Exclusive with observations. |
observations |
map name → path | Per-stream file paths, keyed on the observations { } block names in the .camdl. Exclusive with file. |
holdout_after |
time spec | Temporal split: observation rows at t > this value are removed from training (see below). Exclusive with holdout. |
holdout |
map name → path | Explicit held-out files, tail-only; the training streams stay whole and the files’ bytes are digested into the fit run_id. Exclusive with holdout_after. |
Exactly one of file / observations must be set. The observation model (likelihood family) and projection (which flow or compartment is accumulated) are declared in the .camdl; fit.toml supplies only the file paths.
A holdout declaration is applied when the fit loads its data (apply_holdout_declaration, fit/runner.rs), and only on the fit path: pfilter, profile and compare’s holdout scoring see the full series.
holdout_afteris a bare model time, a date, or an observation-anchored expression (last_obs - 6 weeks; anchors resolve over the union of the bound streams). Every row witht >the resolved boundary is withheld from training. The boundary must cut strictly inside the observed range — at or before the first observation nothing would be left to train on, at or after the last nothing would be withheld — and either is a hard error.[data.holdout]names a stream and a file of rows strictly after that stream’s last training time. An interleaved holdout is refused; only tail holdouts are supported.
The fit prints one line stating the training window and how many rows were withheld (for example holdout: training window [0, 15]; 5 observation row(s) at t > 15 withheld from training), and records the applied window in fit.meta.json. camdl compare scores such a fit out of sample on the withheld rows (stamped held-out tail, with the sealed train_end); a fit without a holdout is scored in-sample. camdl data split, which writes separate train/holdout TSVs for a second fit, remains an alternative.
[synthetic] (SyntheticSpec, config_v2.rs):
| key | type | required | default | meaning |
|---|---|---|---|---|
true_params |
string | yes | — | Flat TOML of name = value ground truth. Anchored at the working directory. |
sim_seeds |
"N:M" range or list of ints |
yes | — | One dataset per seed. Duplicates rejected; a malformed range ("1-20") errors. |
datasets |
int | no | len(sim_seeds) |
Must equal len(sim_seeds) when supplied. |
scenario |
string | no | none | Scenario applied during generation only, not during fitting. |
backend |
chain_binomial/gillespie/ode |
no | chain_binomial |
Forward backend used to generate the datasets. Distinct from the method’s backend. |
Where scoring begins is not a fit.toml concern. Every incidence stream declares what each row covers (covers = ..., or window_start/window_stop columns — camdl-data-spec.md, “What a row covers”), and a declared first period opens where it says: the span from t_start to that opening is simulated and scored nowhere, and the first datum is scored against its own period. A first period opening before t_start is a load error naming both times. The former condition_from key (string or per-stream table) that hand-placed this boundary is removed; detect_removed_condition_from (config_v2.rs) refuses it ahead of the strict deny_unknown_fields parse — at the top level or anywhere under [data] — with a message naming the replacement, so a stale config fails with a migration rather than a bare unknown-field error. camdl pfilter, camdl profile and camdl fit predict bind the same declared periods, so a fixed-θ loglik and a predictive row cover the window the fit scored.
6.3 [estimate] — free parameters
Each entry is [estimate.<param>] (or an inline table under [estimate]). EstimateSpecV2 (config_v2.rs):
| key | type | default | meaning |
|---|---|---|---|
bounds |
[lo, hi] |
the model’s in [lo, hi] |
Search box. May only narrow the model’s declared range; loosening is an error. |
start |
float | a draw within the bounds | The base point: where starts = "single" puts every chain, and where uniform puts chain 1; the other spread rules read it only at one chain, and the sourced rules not at all. Under pmmh/mh it is also the point of the pre-run likelihood check and of the initial proposal scale. |
prior |
inline table | the model’s ~ declaration |
See the wire format below. Required in some form for pgas/pmmh/mh. |
perturb_only_at_t0 |
bool | false |
Perturb at t=0 only, not at every observation — the IF2 schedule for an initial-state parameter. Accepted only under if2: every other method refuses the config at load, since it would read the flag nowhere. Required by ic_free unless the model’s init { } declares a law, which supplies the same t=0 spread. |
rw_sd |
float | auto-scaled from bounds | IF2 per-parameter random-walk SD, on the natural scale; pmmh/mh also read it for the initial proposal SD. |
An entry with no fields at all (beta = {}) is legal: it means “estimate this, take the bounds and the prior from the model.”
Transforms (runner.rs, derive_transform_with_bounds; sim::inference::types::Transform::for_domain) map the real line onto the resolved bounds, chosen by which ends are finite:
| resolved bounds | declared type | transform (record tag) |
|---|---|---|
(−∞, ∞) |
any | θ = z (identity) |
[lo, ∞) |
any | θ = lo + eᶻ (lower_bounded) |
(−∞, hi] |
any | θ = hi − eᶻ (upper_bounded) |
[lo, hi], 0 < lo |
rate, positive, count, duration |
ln θ = ln lo + ln(hi/lo)·σ(z) (log_interval) |
[lo, hi], otherwise |
any | θ = lo + (hi − lo)·σ(z) (interval) |
No transform clamps, and every finite end is z = ±∞, so narrowing in fit.toml genuinely narrows the IF2 search and the samplers’ target. Resolution order is fit.toml [estimate].bounds → the model’s in [lo, hi] → the range the declared type carries: [0, 1] for probability; (−∞, ∞) for real and instant; [0, ∞) for rate, positive, count and duration. A parameter whose IR records no type is refused. The record (fit.meta.json partition) shows an unbounded real as search: ["-inf", "inf"].
Prior wire format. Externally tagged inline tables, matching what the OCaml compiler emits for in-model ~ priors (PriorDist, rust/crates/ir/src/parameter.rs). Field names are exact — normal takes mean/sd, log_normal takes mu/sigma:
prior = { uniform = { lower = 0.0, upper = 1.0 } }
prior = { uniform = {} } # over the param's bounds
prior = { normal = { mean = 0.0, sd = 1.0 } }
prior = { log_normal = { mu = -1.2, sigma = 0.5 } }
prior = { half_normal = { sigma = 1.0 } }
prior = { beta = { alpha = 2.0, beta = 5.0 } }
prior = { gamma = { shape = 2.0, rate = 1.0 } }
prior = { exponential = { rate = 1.0 } }
prior = { log_uniform = { lower = 1e-5, upper = 1e-2 } }
prior = { truncated_normal = { mean = 0.7, sd = 0.2, lower = 0.3, upper = 1.0 } }
prior = { flat = {} } # fit.toml only{ uniform = {} } is uniform over the parameter’s resolved bounds (fit bounds falling back to the model’s in [lo, hi]); it errors when neither supplies them. truncated_normal’s lower/upper must equal the resolved bounds — the prior’s support and the search box are one interval. { flat = {} } has no DSL counterpart: it is the accountable opt-in to an improper-uniform prior, recorded in provenance as flat_explicit.
Prior precedence (three tiers, validate_priors_present, config_v2.rs): a fit.toml prior wins over the model’s ~ declaration; an explicit { flat = {} } counts as declared. A sampler method whose estimated parameter has none of the three is a hard error — fit run never falls back to flat silently, because downstream consumers treat the chain as the canonical posterior. (camdl profile falls back to flat without a warning, recording flat_fallback; every profile cell is a maximization that reads no prior.)
Prior support (validate_prior_transform_compat) is enforced before any fitting: the resolved bounds must lie within the prior family’s support — [0, ∞) for log_normal, half_normal, gamma and exponential, [0, 1] for beta, [lower, upper] for uniform and log_uniform; normal and flat accept any bounds, and truncated_normal’s truncation must equal them. Every prior reads ln θ and ln(1 − θ) from the transformed coordinate, so the density is exact under every transform.
6.4 [fixed] — held-constant parameters
FixedParams (config_v2.rs). Three sources, and the union of [estimate] and [fixed] must be exactly the model’s parameter set:
| key | type | meaning |
|---|---|---|
from_file |
string | Bulk-load a flat TOML of name = value. Resolved against the working directory, not the file. |
from_scenario |
string | Bulk-load a named scenario block from the .camdl, following its compose = [...] chain. |
<param> = <n> |
float | Inline values. Override from_file entries on collision. |
from_scenario is mutually exclusive with both from_file and inline values: allowing an override would make the fit a hybrid that corresponds to nothing in the .camdl. It imports the values camdl simulate --scenario runs at: every value the scenario sets, with the scenario’s scale factors applied, through composed presets. A scenario that scales a parameter it does not set is refused, since the import has no value for the factor to multiply; set the parameter in the scenario as well. A scenario that sets or scales a parameter in [estimate] is refused (§1.3): to estimate a parameter a scenario sets, name a scenario that does not set it.
6.5 [method] — the inference method
A file carries one [method]: one algorithm with its settings and the chains’ starts rule (§6.6). A second way of fitting the same problem is a second file — camdl fit new --from fit.toml fit-pgas.toml — and the two share a fit-level hash (§6.8). The table is tagged by algorithm (Method, config_v2.rs, Algorithm with #[serde(tag = "algorithm")]) and carries an explicit backend. Each algorithm is valid on exactly one backend; the pair is checked at load against the METHODS registry (rust/crates/cli/src/fit/methods.rs), which is the single source of truth for camdl fit methods, the runtime banners, and the invalid-pair error.
algorithm |
backend |
status | category | role |
|---|---|---|---|---|
if2 |
chain_binomial |
stable | inference | Iterated filtering → MLE. |
pgas |
chain_binomial |
stable | inference | Particle Gibbs + NUTS on θ → posterior. Default Bayesian path. |
pmmh |
chain_binomial |
stable | inference | Pseudo-marginal MH → posterior. Degrades for T > 500. |
pfilter |
chain_binomial |
stable | diagnostic | Bootstrap particle filter at fixed θ → loglik, ESS, prequential. |
nl-sbplx |
ode |
beta | inference | NLopt Sbplx → deterministic MLE. Robust at bounds. |
nl-bobyqa |
ode |
beta | inference | NLopt BOBYQA → deterministic MLE. Faster, fails at bounds. |
nl-lbfgs |
ode |
beta | inference | NLopt L-BFGS with the forward-sensitivity gradient → deterministic MLE; needs a differentiable model. |
mh |
ode |
beta | inference | Gradient-free MH on the ODE marginal likelihood → posterior. |
The ode-backend samplers fit the deterministic marginal likelihood p(y | θ, ODE skeleton), not the stochastic p(y | θ) — a different statistical object, appropriate for equilibrium or large-population models. gillespie is a forward-simulation backend with no inference interface and is rejected at parse.
Keys common to every method: algorithm, backend, starts (§6.6).
if2 — chains, particles, iterations, cooling required.
| key | default | meaning |
|---|---|---|
cooling |
— | Fraction of the initial perturbation SD remaining at cooling_target_iters. |
cooling_target_iters |
50 |
pomp’s cooling.fraction.50 convention; decoupled from iterations. |
loglik_eval |
table | Clean re-scoring of candidate θ̂. n_particles = 4000, n_replicates = 8, combine = "log_mean_exp" (or "mean"). |
gate |
table | a_thresh = 1.01 (chain-agreement  ceiling), decibans_thresh = 30.0 (inter-chain loglik spread floor). |
dt_check |
table | Post-fit Richardson dt-convergence audit. enabled = true, n_halvings = 2; n_particles/n_replicates/combine inherit loglik_eval; threshold_nats defaults per backend (2.0 chain_binomial, 0.5 ode_rk4). |
iterations must be ≥ 1. IF2 has no extension dimension, so --resume does not apply: its cooling schedule is determined by the total iteration count, and restarting mid-schedule is statistically incoherent.
pgas — chains, particles, sweeps required.
| key | default | meaning |
|---|---|---|
burn_in |
2000 |
Discarded sweeps. Must be < sweeps. |
thin |
5 |
Retain every k-th post-burn-in sweep. |
max_tree_depth |
10 |
NUTS tree-depth ceiling for the θ|X update. |
trajectory_warmup |
0 |
CSMC-only sweeps before parameter updates begin. |
binomial |
btpe |
Binomial sampler for propagation draws: btpe, or btrs (faster, exact, not bit-compatible). |
csmc_sweeps_per_nuts |
1 |
CSMC trajectory updates per parameter update. |
n_trajectories |
200 |
Posterior trajectories written to disk. Output-shaping, but keyed. |
dense_mass |
true |
Full-covariance NUTS metric; false for diagonal. |
use_nuts |
true |
false falls back to MH-within-Gibbs for θ|X. |
ancestor_sampling |
true |
false runs plain particle Gibbs without the ancestor-sampling move — a valid kernel on its own, since ancestor sampling is a mixing addition and removing it leaves the invariant distribution intact. A diagnostic control, not a recommendation: run the method both ways to measure how much the ancestor move is buying on this model. If path renewal is unchanged with it off, the move is contributing nothing and the freeze is structural in the genealogy. |
pmmh — chains, particles, iterations required.
| key | default | meaning |
|---|---|---|
burn_in |
5000 |
Must be < iterations. |
thin |
10 |
|
adapt |
true |
Haario adaptive Metropolis on the proposal SDs. |
adapt_start |
300 |
MCMC step at which adaptation begins. |
mh — chains, iterations required. Reuses the PMMH chain and adaptive-proposal machinery with the particle-filter likelihood swapped for the deterministic ODE evaluation, so it carries no particles.
| key | default | meaning |
|---|---|---|
burn_in |
5000 |
Must be < iterations. |
thin |
10 |
|
adapt |
true |
|
adapt_start |
300 |
|
burnin_dt |
none | Coarse RK4 step for the unscored warm-up [t_start, first_obs). < dt is refused and = dt is off; prevalence-scored streams, rk4, no balance/events. Identity-defining. |
pfilter — particles required. No chains (it is always one); starts names the one point it scores — the base point, or the point a sourced rule such as { from_mle = "@scout" } names.
| key | default | meaning |
|---|---|---|
replicates |
none | Independent filter passes; the run reports loglik mean ± SD across them. |
record_ancestry |
false |
Record per-step ancestor indices for smoothing-path reconstruction. |
record_prequential |
true |
Record per-step predictive samples and log-likelihoods for camdl compare. |
nl-sbplx / nl-bobyqa / nl-lbfgs — backend, chains required (NloptConfig, config_v2.rs). All three read the same keys.
| key | default | meaning |
|---|---|---|
tolerance |
1e-6 |
NLopt xtol_rel. |
max_evals |
5000 |
Per-chain objective-evaluation budget; hitting it is a soft failure. |
gate |
table | Two-leg convergence gate, same shape as IF2’s. |
chains here is the number of independent multi-start optimizations; starts = "single" defeats multi-start, since every chain then converges from the same point.
nl-sbplx and nl-bobyqa are derivative-free: one objective evaluation is one ODE solve. nl-lbfgs is the gradient method — one objective evaluation is one augmented solve carrying ∂x/∂θ alongside the state, returning the log-likelihood and its gradient together (the forward-sensitivity gradient, sim::inference::ode_grad::det_grad). The budget and the progress step count that unit for all three.
Because it takes a gradient, nl-lbfgs accepts only a differentiable model — the gradient capability check refuses an unsupported rate or observation gradient, an adaptive rk45 integrator, a scheduled effect, or an initial condition the gradient path cannot seed. It is checked at config validation, before any leaf is claimed, and the refusal names nl-sbplx as the derivative-free alternative on the same likelihood. If the gradient later turns out not to be computable at some θ the search reaches, that chain fails with the reason and the θ rather than scoring the point badly and continuing — L-BFGS has no direction to search in there.
Observation alignment. [config] obs_alignment is resolved per algorithm (resolve_obs_alignment, methods.rs). if2 and pfilter step exactly to observation times and reject "snap". pgas uses a uniform grid and rejects "exact". pmmh is exact. The ODE algorithms score on the integrator grid and reject the key entirely.
IC-free support. ic_free = true is honored by if2, and by pfilter / pmmh on a model whose init { } declares a law (methods.rs::validate_ic_free). pgas and the ODE algorithms score every observation unconditionally, so the combination is rejected rather than allowed to compute the unconditional likelihood under a banner claiming otherwise. pfilter / pmmh on a deterministic init { } are rejected for the other reason: their bootstrap filter draws x₀ per particle, but every such draw returns the same state, so the first reweight has nothing to discriminate between (gh#732).
6.6 Chain starts: starts
One key on [method] answers where the chains begin. Its wire form is a bare string for a rule that needs no source, or a one-key table naming the source (ChainStarts, rust/crates/cli/src/fit/starts.rs):
starts = "from_prior"
starts = { from_posterior = "@scout" }A rule is one of two kinds, and the kind is what the diagnostics act on. A spread rule gives every chain its own independent point, so a between-chain R̂ over the chains is a verdict; a point rule puts every chain at one point, so the chains agree by construction and R̂ cannot say whether the posterior was explored.
starts |
kind | where the chains start |
|---|---|---|
from_prior |
spread | One draw per chain from each parameter’s resolved prior (fit.toml prior over the model’s ~, §12.1). The default whenever every estimated parameter has one. |
uniform_unconstrained |
spread | i.i.d. U(-2, 2) on the unconstrained scale, squashed and mapped into bounds. Boundary-avoiding and scale-invariant; Stan’s default. The fallback default. |
lhs |
spread | Latin-hypercube stratified over bounds, log-scale-aware for Log parameters. Best full-bounds coverage at low chain counts. |
uniform |
spread | Per-chain uniform draw within natural-scale bounds. Clumps for Log parameters. |
{ from_posterior = "<handle>" } |
spread | One row per chain, drawn uniformly with replacement from a fit’s draws.tsv (or a draws TSV path). Refused on a source that wrote no draws.tsv. |
single |
point | Every chain at the base point ([estimate].start, else the model’s value). |
{ from_mle = "<handle>" } |
point | Every chain at a stored fit’s estimate (its leaf’s fit_state.toml). |
{ from_params = "<path>" } |
point | Every chain at the point in a hand-written flat params TOML. |
uniform, lhs, and uniform_unconstrained collapse to the base point at chains = 1; the source-reading rules do not. At two or more chains they refuse a parameter with no finite box to draw within (an unbounded real or instant on (−∞, ∞), or an unbounded rate, positive, count or duration on [0, ∞)), asking for bounds or a different rule; from_prior draws such a parameter from its prior. A rule applies only to parameters in [estimate]; anything in [fixed] takes its declared value.
The default. With no starts in the file and no --starts on the command line, fit run resolves the rule against the problem before the identity is taken (Method::resolve_starts): from_prior when every estimated parameter’s resolved prior is a distribution the chains can be drawn from, uniform_unconstrained otherwise. The startup block says which and why — starts: from_prior (one independent draw per chain) — default: every estimated parameter declares a prior, or … uniform_unconstrained … — default: no sampleable prior on beta. A file that spells the default out hashes the same as one that omits it, because the hashed payload always carries the resolved rule.
Handles. A sourced rule names its source by a fit handle (handle.rs): @<label> (the one run given that --label), the source’s fit.toml path, its segment directory, a fit-id prefix, its leaf directory, or a prefix of the leaf’s own run_id. A segment handle names a leaf only while the segment holds exactly one, and a handle resolving to several runs is refused with the runs listed and the axes on which they differ ([method] settings or --starts, fit seed, the problem, the camdl build). The source is resolved before the model is loaded, and its stored convergence verdict is checked where it is consumed: a from_mle or from_posterior source whose verdict is not converged is refused, naming the verdict, unless --allow-nonconverged-source is passed (§7.3).
starts is identity-bearing: Method::identity_payload folds the resolved rule into the method hash, and a sourced rule’s consumed file (the leaf’s fit_state.toml or draws.tsv, a params TOML) enters the method’s deps by content and by the source run’s run_id, so two runs differing only in starts are two artifacts and a regenerated source re-keys its consumer (§9.2.4). A sourced rule enters the method hash by its kind alone (from_mle, from_posterior, from_params), not by its handle as typed: @scout, the scout’s leaf directory and a prefix of its run_id name one source and key one run. camdl profile --starts keys the same way. The handle as typed is kept, unhashed, in run.json inputs.starts_source.
A refused spread start is redrawn (gh#887). A spread rule is a lottery over one draw, so a start the filter cannot score is one unlucky draw rather than a verdict on the chain: it is redrawn under the same rule, at a seed derived from the run’s seed and the attempt index, up to MAX_START_ATTEMPTS = 10 per chain, and the chain is refused only when every attempt fails. A point rule has nothing to redraw and gets one try; so do uniform, lhs and uniform_unconstrained at one chain, whose single draw is the base point. IF2 scores every chain’s start with one particle-filter pass before the swarm is perturbed (runner::preflight_spread_starts); PGAS retries on a start whose complete-data log-posterior is still non-finite after the first trajectory update; PMMH retries on the init-eval’s PFDegenerate. Each attempt is announced (chain 0: start 3 of 10 refused (…); drawing another) and the refusal, when it comes, says how many were tried: none of 10 starts drawn understarts = from_posterior could be scored by the filter; the last: EssCollapsed at obs_window=…. Skip-and-continue after a refusal is unchanged (§10.7).
What a run records. Every multi-chain sampler and IF2 write chain_starts.tsv — the point every chain actually began at, before any perturbation, and every attempt a retry rejected on the way there — under a header naming the rule and counting the rejected draws (# camdl chain_starts; starts=from_mle @scout; chains=4; kind=point; retried=0). Its columns are chain_id (the number of the chain_N/ directory beside it and the number every stderr refusal and bad_init record uses), attempt (0-based), status (accepted — the start the chain ran from; rejected — a draw the filter could not score, a redraw followed; refused — the last attempt, also unscoreable, the chain did not run; skipped — the chain ran from this start and was dropped before the end, so the fit’s results do not include it), source, one column per estimated parameter, then ess (the filter’s ESS at refusal, blank otherwise) and reason (blank for an accepted row; for a skipped row, why the chain was dropped); one row per attempt in (chain, attempt) order, so a healthy run has one accepted row per chain at attempt 0. The file is written before the sampler runs and, when IF2 skips a chain mid-run (its particle-filter degeneracy watchdog fired), written again with that chain’s row marked skipped. That file, not the config, is the authority on what a run did. fit_state.toml records the count as n_chains (started) and n_good_chains (reached the end); an IF2 run whose n_good_chains is below n_chains has the gate verdict incomplete (§13.1), not not_assessed, even when one chain is left. fit_state.toml carries the same rule as chain_init_source beside chain_starts_kind = "spread" | "point", and fit summary prints both under the leaf’s identity line, with the run id the handle resolved to when the run started (run.json inputs.starts_source.run_id):
seeded from: from_mle @scout, run 4b1e0c9a2f77 (every chain at one point)
A multi-chain sampler leaf whose kind is point is read with its R̂ withheld: fit summary reports R̂ — not assessed: all 2 chains started at one point (starts = from_mle @scout) in place of a verdict, fit predict stamps its bands the same way, and the ESS — a within-chain quantity — is kept. Writing the point rule is the acknowledgement; fit run says so once at startup for a sampler with chains > 1. An IF2 run keeps its  band under the same header, since IF2’s perturbation re-spreads the swarm after the start.
Base-point precedence (runner.rs), lowest to highest: the model’s declared value, [fixed], [estimate].start. A sourced point rule replaces the base point for every estimated parameter it carries.
6.7 [provenance] — lineage metadata
[provenance]
derived_from = "fits/01_all_free.toml"
reason = "free the reporting fraction"Both fields are optional strings (FitProvenance, config_v2.rs). No runner reads them; camdl fit new writes derived_from when deriving one config from another, and the block is otherwise for human navigation.
It is nonetheless identity-bearing: the fit-level hash covers the whole serialized config with only method, fit_seeds, and output_dir removed (cas.rs), so editing reason changes the fit hash and forces a re-fit into a new segment.
6.8 Where output goes
Fits are content-addressed. There are no configured output paths beyond the root — the directory names are hashes of the inputs, and the layout is factored into three levels (fit/cas.rs, module comment):
<root>/fits/<stem>-<h8>/ fit level
model.ir.json model.render.json model.graph.json
model.camdl.original fit.toml.original fit.meta.json
<method>-<h8>/ method level
<seed_N>-<h8>/ seed level (the leaf)
run.json fit_state.toml chain_starts.tsv
chain_0/ … chain_{N-1}/
One index base. Every index camdl shows a user counts from 0 — this is the single rule, stated once here and relied on everywhere below. A fit’s chains are 0 … N-1 in every place 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, --exclude-chains, the chain column of draws.tsv and trajectories.tsv, the chain column fit predict --by-chain adds, progress.json’s per-chain rows and cross_chain_compat.json’s chains. simulate’s replicate, draw and point_id count from 0 too. Joining any two of these takes no arithmetic, and no artifact carries a second base to convert from. A leaf written by a camdl that numbered its directories from 1 (chain_1/ with no chain_0/) is refused by every reader that addresses a chain by name or reads the posterior cloud — fit summary (every --format), fit predict, fit table --quantity, compare when it derives a prequential, simulate --draws posterior, and fit run --resume, which refuses the base leaf before copying any chain state — rather than read with every chain shifted by one.
<root>isoutput_dirfrom thefit.toml(anchored at thefit.toml), else$CAMDL_OUTPUT_DIR(anchored at the working directory), elseresults(run_paths::DEFAULT_OUTPUT_ROOT).fit runhas no--output-dirflag.<stem>is thefit.toml’s file stem;<h8>at the fit level is the first eight hex digits of the fit hash — the whole-IR model digest, the per-stream training and holdout data digests, the canonicalized config (lessmethod/fit_seeds/output_dir), and the engine version. Two files that share a problem and differ in[method]share this hash and sit in sibling segments named by their stems.<method>is the algorithm’s name; the method<h8>folds the method’sidentity_payload(its settings and the resolvedstartsrule) with itsdeps, so a posterior file startingfrom_mle = "@scout"keys on the scout leaf’s identity.- The leaf is per seed.
run.jsonis the CAS record; there is no manifest and no fit-widerun.json— the fit level is a path segment, andfit.meta.jsonis its sidecar (model hash, config archive). A run’s label is on its ownrun.json(provenance.label).
Per-algorithm leaf contents: IF2 writes mle_params.toml, final_params.toml, diagnostics.tsv, chain_evaluations.tsv, and chain_N/{final_params.toml, parameter_traces.tsv}. The samplers write draws.tsv, <algorithm>_summary.json (pgas_summary.json, pmmh_summary.json, mh_summary.json), and chain_N/trace.tsv; PGAS and PMMH also write chain_N/resume_state.bin. The NLopt optimizers write mle_params.toml and chain_results.tsv. A pfilter method with record_prequential writes prequential.{tsv,json}.
Every <algorithm>_summary.json carries schema = "camdl.fit-summary/v2", and every reader requires it: a file without the tag, or with a tag this camdl does not know, is refused by name rather than read. The tag exists because two of its keys changed meaning under unchanged names — rhat was the classic Gelman–Rubin statistic and is now max(rank-normalized split-R̂, folded split-R̂); ess was a sum of per-chain Geyer estimates suppressed to NaN above R̂ 1.1 and is now the cross-chain bulk-ESS, never suppressed. Absence of the tag therefore means the pre-tag vintage, and nothing else in the file distinguishes the two. Re-running a fit refreshes it; a stored fit from before the tag is read by the camdl that wrote it.
v2 is the same statistics under one renamed key: the algorithm is named by method, not stage. A rename under an unchanged tag is the vintage problem the tag exists to prevent, so it is a version rather than a silent edit.
A completed leaf is reused on a second identical invocation (“cache hit”); pass --force to re-run it: the old leaf is quarantined and the result stored afresh.
Fits are addressed downstream by handle (fit/handle.rs, FitRef): @label, a fit.toml path, a leaf or problem-folder directory, or a hash prefix (a run’s run_id, else the fit id). @label, a leaf directory and a run_id prefix name one run; the others name a problem folder. Ambiguity is listed git-style rather than guessed.
A label names exactly one run and never moves. It is recorded on that run’s run.json (provenance.label) by fit run --label or camdl label <run_id>. fit run --label L is refused before any sampling when another run that has not failed holds L, when the run is already stored under another label, or when the invocation writes several runs (several fit_seeds, synthetic datasets or sweep points). camdl label refuses a held label, a run that already has a different label, and a fit id, which names a problem folder rather than a run. A run started from a labelled source (from_mle, from_posterior) records the source leaf’s run_id in deps, so no later label can change what it was started from.
6.9 Load-time validation
FitConfig::from_toml_str (config_v2.rs) runs the migration detectors before the typed parse and the [method] key check on its result; FitConfig::validate then runs the semantic checks once the model IR is loaded and [fixed] from_scenario has been expanded. Everything below fires before the method executes.
Parse-time.
A relocated
[config] backend:`[config].backend` has moved to `[synthetic].backend` (gh#241).A
[stages]table. There is no silent conversion: the loader prints the rewrite, in the file’s own declaration order, naming the last-declared stage as the file’s[method]and every other stage as a file of its own. A chained stage (init_mle) is a decision the message hands back, because the file cannot name a handle that does not exist until the upstream has run:error: legacy table `[stages.posterior]` replacement: rename to `[method]` and run it with camdl fit run fit.toml a file carries one `[method]`; put `[stages.scout]` in its own file `init_mle = "scout"` has no replacement in the file: it started every chain at scout's point estimate, which makes R̂ uninformative. Run scout first and, if a warm start is wanted, write one of starts = { from_posterior = "@scout" } # one draw per chain (keeps R̂ meaningful) starts = { from_mle = "@scout" } # every chain at one point (R̂ not assessed) `init = "uniform_unconstrained"` becomes `starts = "uniform_unconstrained"` See `camdl docs fit-toml`.An
init = "survey_top_k"(or asurvey_path/survey_top_k_n) adds`init = "survey_top_k"` … was removed: a survey landscape is not a posterior. Use `starts = "from_prior"`, or run a short fit and write `starts = { from_posterior = "@handle" }`.An empty[stages]is refused withreplacement: delete it.The removed top-level
fit_starts:`fit_starts` is no longer a fit.toml key. Chain starts are the `starts` key of `[method]`: `from_prior` is the default whenever every estimated parameter declares a prior, so `fit_starts = "prior"` is simply deleted; `fit_starts = "model_default"` is `starts = "single"`. See `camdl docs fit-toml`.An unknown top-level key:
unknown field `dt`, expected one of `model`, `data`, `synthetic`, `fit_seeds`, `simplex_groups`, `output_dir`, `estimate`, `fixed`, `method`, `config`, `scenario`, `enable`, `disable`, `ic_free`, `provenance`An unknown key inside
[method].Algorithmis internally tagged, so serde cannot deny unknown fields on it; a post-parse pass compares the raw keys against the set the parsed method serializes back to, plusstarts(validate_method_keys,config_v2.rs):unknown key `chains` in [method] (algorithm = "pfilter").allowed keys: algorithm, backend, particles, record_ancestry, record_prequential, replicates, startsThe keys the split retired name their replacement instead of “unknown”:
init→`init` is now `starts`: write `starts = "lhs"`.,init_mle→`init_mle` has no replacement key: a warm start from a stored fit is `starts = { from_posterior = "@handle" }` (one draw per chain, keeps R̂ meaningful) or `starts = { from_mle = "@handle" }` (every chain at one point, R̂ not assessed)., andsurvey_path/survey_top_k_n→ the survey message above. The two NLopt variants are newtype-wrapped structs and get plain serdedeny_unknown_fieldsfor their own keys:unknown field `zzz`, expected one of `backend`, `chains`, `tolerance`, `max_evals`, `gate`.Unknown enum values — an unrecognized
algorithm(via a missing tag),backend(unknown variant `gillespie`, expected `chain_binomial` or `ode`), or a prior whose field names do not match a catalogue entry (data did not match any variant of untagged enum EstimatePriorSpec); an unknownstartsrule (unknown starts rule `foo`; expected one of uniform_unconstrained, lhs, uniform, from_prior, single, or a sourced rule { from_posterior = "@handle" }, { from_mle = "@handle" }, { from_params = "params.toml" }).
Semantic, in the order validate applies them.
- Exactly one of
[data]/[synthetic].fit config has neither [data] nor [synthetic] — one must be supplied. [synthetic]internal consistency — non-emptysim_seeds, no duplicates,datasets == len(sim_seeds).[data]exactly one offile/observations.[data]: `file = "..."` and `[data.observations]` are mutually exclusive — choose one.fit_seedsnon-empty and duplicate-free.duplicate fit_seed 1 — each seed must be unique to avoid provenance-hash collisions between fitsscenarioexclusive withenable/disable.holdout_afterexclusive withholdout.- Partition.
[estimate] ∩ [fixed] = ∅,[estimate] ∪ [fixed]equals the model’s parameter set exactly. Three distinct errors:parameter `beta` is in both [estimate] and [fixed]; a parameter is either estimated or given a value, not both.parameters neither estimated nor fixed: I0parameters not in model: nope - (algorithm, backend) against the registry. The error names the structural reason, suggests the right alternative, and lists the supported pairs:
[method]: method has algorithm = "if2" with backend = "ode", which is not a supported inference method. ic_freesupport for the method.- IF2
iterations ≥ 1. burn_in < iterations(pmmh,mh) andburn_in < sweeps(pgas), using the defaults when unset — so a short sampler run must setburn_inexplicitly:[method]: burn_in (2000) ≥ sweeps (5) — every sample is discarded as burn-in, so the fit retains no posterior draws (and the post-burn acceptance rate degenerates to 0%). Reduce burn_in or raise sweeps. (burn_in defaults to 2000 when unset.)- Non-empty
boundson every entry that declares them.estimate.beta: bounds [0.6, 0.15] are empty (lo must be < hi) - Simplex groups: ≥ 2 members, every member in
[estimate], no member in two groups, no member withperturb_only_at_t0 = true, no negative lower bound.
validate_priors_present runs next, with the model IR in scope so the ~ fallback is honored — for a sampler method only; priors an optimizer ignores are the normal case under one method per file and draw no warning. Then one note that does not stop the run: a posterior sampler under a point rule (single, from_mle, from_params) with chains > 1, whose R̂ fit summary will report as not assessed (§6.6).
Two checks fire later, at method build rather than at load: the bounds-may-narrow-not-loosen rule (runner.rs) and burnin_dt validity (config_v2.rs).
A file with no [method] passes every check above for the readers that take --fit; fit run alone refuses it:
this fit.toml declares no[method]table, so there is nothing to run. It is a complete problem forsimulate –fit,pfilter –fit,survey –fitandprofile –fit; to fit it, add …
7. The Fit CLI
camdl fit is a group of seven subcommands. One runs inference; three read what a run left behind; two work on configs; one is a static capability listing.
| subcommand | what it does |
|---|---|
fit run |
run the [method] of a fit.toml |
fit summary |
render one fit’s convergence verdict, θ̂ table and provenance checks; --explain defines each column, --parameters adds the model’s parameter legend |
fit table |
walk a results tree and render one row per run |
fit diff |
compare two fit.toml configs |
fit new |
scaffold a new fit.toml derived from an existing one |
fit predict |
write the posterior-predictive (predicted-vs-observed) artifact |
fit methods |
list the supported (algorithm, backend) pairs |
There is no camdl fit list and no camdl fit where. Browsing every cached run — fits, simulations, profiles — is the top-level camdl list; attaching or changing a display label after the fact is the top-level camdl label.
7.1 Invocations
Every command below was run against the worked project of §8 (models/, data/, fits/, results/).
# Run the file's [method].
camdl fit run fits/01_mle.toml --seed 1
# Long fits: force plain progress lines and capture them.
camdl fit run fits/01_mle.toml --seed 1 --progress plain 2>&1 | tee fit.log
# Sweep a [fixed] parameter; repeat the flag for a Cartesian grid.
camdl fit run fits/01_mle.toml --sweep "rho=0.5,0.6"
camdl fit run fits/01_mle.toml --sweep "rho=0.5,0.6" --sweep "k=5,10"
camdl fit run fits/01_mle.toml --sweep "k=lin(5,15,3)"
camdl fit run fits/01_mle.toml --sweep "k=log10(1,100,3)"
# Tag the run so it is findable later, and by another file's `starts`.
camdl fit run fits/01_mle.toml --label mle
# Start the chains from another fit, overriding the file's `starts`.
camdl fit run fits/09_pgas_only.toml --starts from_mle=@mle
camdl fit run fits/09_pgas_only.toml --starts from_posterior=@short-run
camdl fit run fits/09_pgas_only.toml \
--starts from_mle=results/fits/01_mle-2030ba2b/if2-fee126b1/seed_1-e6a47b86
# Read side.
camdl fit summary results/fits/01_mle-2030ba2b
camdl fit summary '@auto rw_sd, take 1'
camdl fit summary results/fits/01_mle-2030ba2b --params-only
camdl fit summary results/fits/01_mle-2030ba2b --explain # define each column
camdl fit summary results/fits/01_mle-2030ba2b --parameters # the parameter legend
camdl fit table results/fits
camdl fit table results/fits --with-method pgas --format md
camdl fit diff fits/01_mle.toml fits/02_posterior.toml
camdl fit new --from fits/01_mle.toml fits/04_derived.toml
camdl fit predict fits/02_posterior.toml --n-draws 20
camdl fit methods
camdl listFit handles. fit summary and fit predict take a handle, not only a path: @<label>, a fit-level hash prefix, a run directory, or the fit.toml itself. A handle that maps to more than one fit is a hard error that lists the candidates:
$ camdl fit summary fits/01_mle.toml --params-only
error: fits/01_mle.toml resolves to 2 fits:
fits/../results/fits/01_mle-2030ba2b
fits/../results/fits/01_mle-cc381195
Pass a run directory or a longer hash prefix to disambiguate.
Chaining files by handle. A fit.toml carries one [method], so a pipeline — an IF2 scout that finds the basin, a PGAS file that samples it — is two files, and the second names the first through its starts:
# fits/02_posterior.toml
[method]
algorithm = "pgas"
…
starts = { from_mle = "@scout" }The handle is resolved when the second file runs, so the scout has to have run (and been labelled) first; a handle that names nothing, or a source that did not converge, is refused where it is consumed (§7.3). A completed leaf is served from the content-addressed store, so re-running the first file is a cache hit:
$ camdl fit run fits/01_scout.toml --label scout
── method: if2 ──
cache hit — reusing fits/01_scout-77595169/if2-fee126b1/seed_1-e6a47b86
The flags the split removed are parsed only to reject them, so the error names each one’s replacement rather than printing unexpected argument:
$ camdl fit run fits/09_pgas_only.toml --stage posterior --init from_prior
error: these `camdl fit run` flags were removed with the `[stages]` → `[method]` split (proposal 2026-09-08-workflow-first-fit-config):
--stage posterior: a fit.toml carries one `[method]`, so there is nothing to select; drop the flag. A second way of fitting the same problem is a second file (`camdl fit new --from fit.toml fit-posterior.toml`).
--init from_prior: write `--starts from_prior`.
See `camdl fit run --help` and `camdl docs fit-toml`.
--posterior <P>, --mle <P> and --params <P> are answered with --starts from_posterior=<P> / from_mle=<P> / from_params=<P>; --survey-path / --survey-top-k with the note that the survey_top_k rule was removed (a survey landscape is not a posterior) and --starts from_prior or a from_posterior from a short run is the replacement; --allow-nonconverged-scout with --allow-nonconverged-source; and the older --starts-from / --init-method spellings likewise.
The fit.toml side is the [stages] rejection of §6.9.
7.2 CLI Type
fit run takes the config path plus four groups of flags: run control, chain starts, the dt-convergence audit, and per-algorithm overrides. The run-control core:
pub struct FitRunArgs {
/// Fit configuration file (v2 TOML)
pub config: PathBuf,
/// Where the chains begin, overriding the file's `starts`: a rule name,
/// or `name=source` for a sourced rule (`from_mle=@scout`).
#[arg(long, value_name = "SPEC")]
pub starts: Option<ChainStarts>,
/// Start from a `from_mle` / `from_posterior` source whose stored
/// verdict is not converged.
#[arg(long)]
pub allow_nonconverged_source: bool,
/// Rayon thread cap; 0 = all logical cores. Bit-identical regardless.
#[arg(long, default_value_t = 0, env = "CAMDL_PARALLEL")]
pub parallel: usize,
/// RNG seed (default: 1)
#[arg(long)]
pub seed: Option<u64>,
#[arg(long)]
pub force: bool,
/// Extend a completed PGAS/PMMH run from a base run, addressed by
/// run_id prefix or leaf path.
#[arg(long, value_name = "BASE_REF", conflicts_with = "force")]
pub resume: Option<String>,
/// Cartesian sweep over a [fixed] parameter (repeatable).
/// SPEC is `V1,V2,...` | `lin(min,max,n)` | `log10(min,max,n)`.
#[arg(long, value_name = "NAME=SPEC")]
pub sweep: Vec<SweepSpec>,
/// Display label, 1–64 chars of `[A-Za-z0-9 ,._-]`.
#[arg(long, value_name = "TEXT")]
pub label: Option<String>,
}Note the shapes. --resume carries a base reference, not a boolean; --parallel is a plain usize with a 0 default meaning “all cores”; there is no --output-dir (the output root is the fit.toml’s output_dir key, or CAMDL_OUTPUT_DIR, or ./results) and no --skip-chains (chain exclusion is a read-side view: fit summary --exclude-chains, fit predict --exclude-chains, fit table --exclude-chains).
--force on fit run recomputes a method leaf that already exists: the stored leaf is moved to <root>/.quarantine/ (never deleted) and the fresh result is stored at the same address. Without the flag the same command reports a cache hit and exits 0. A leaf another live process is still writing is not displaced; the run stops with an in-progress error naming the pid. simulate and survey take --force with the same meaning (WritePolicy::Force, resolve::check_reuse); pfilter and profile have no --force.
--resume <BASE_REF> extends a completed PGAS or PMMH run: the base leaf is read read-only and the longer chain is written to a new content-addressed leaf keyed on the new sweeps / iterations with a dependency on the base — a distinct deterministic artifact, not bit-identical to an uninterrupted fit of the same length. The new leaf holds the whole chain, the base’s rows and the new ones, and records where each segment begins (§9.5, “A resumed leaf”). Running the same resume again, of the same base to the same length, is a cache hit on that leaf, as any identical invocation is. The file’s starts (or --starts) is resolved as on any run, so a file that starts from another fit resumes like any other:
$ camdl fit run fits/09_pgas_only.toml --starts from_mle=@mle
stored pgas · …/09_pgas_only-649b2ecc/pgas-7107153d/seed_1-e6a47b86/
# raise `sweeps` in the toml, then extend that leaf
$ camdl fit run fits/09_pgas_only.toml --starts from_mle=@mle \
--resume results/fits/09_pgas_only-649b2ecc/pgas-7107153d/seed_1-e6a47b86
chain 0: resuming from sweep 100
chain 1: resuming from sweep 100
stored pgas · …/09_pgas_only-649b2ecc/pgas-05ea6411/seed_1-e6a47b86/
Chain starts. --starts <SPEC> overrides the file’s starts with the same rules §6.6 lists — a bare name, or name=source for a sourced rule:
--starts |
where the chains start |
|---|---|
from_prior |
one draw per chain from each parameter’s resolved prior (the default when every one has a prior) |
uniform_unconstrained |
i.i.d. U(−2, 2) on the unconstrained scale, mapped inward (the default otherwise) |
lhs |
Latin-hypercube stratified inside bounds |
uniform |
per-chain uniform draw inside [estimate] bounds |
from_posterior=<handle> |
one row per chain from a fit’s draws.tsv (or a draws TSV path) |
single |
every chain at the seeded base point |
from_mle=<handle> |
every chain at a fit’s estimate |
from_params=<toml> |
every chain at the point in a flat params TOML |
The override is keyed into the run’s identity like the file’s own starts, and a sourced rule’s source is refused when its stored verdict is not converged unless --allow-nonconverged-source is passed.
The dt audit. --no-dt-check skips the post-fit Richardson dt-convergence check, --dt-check-strict tightens its threshold (0.5 nats for chain_binomial, 0.1 for ode_rk4, against routine defaults of 2.0 / 0.5), and --dt-check-halvings <N> sets how many halvings it evaluates (default 2).
Per-algorithm overrides. Each writes through to the method identity, so an overridden run is a distinct artifact rather than a cache hit on the un-overridden one. A flag is accepted only by the methods in its row; on any other method fit run refuses it before any work, naming the flag, the method, and the methods that read it.
| flag | overrides | methods that read it |
|---|---|---|
--decibans-thresh <DB> |
[method.gate].decibans_thresh |
if2, nl-sbplx, nl-bobyqa, nl-lbfgs |
--no-dt-check |
[method.dt_check].enabled = false |
if2, mh, nl-sbplx, nl-bobyqa, nl-lbfgs |
--dt-check-halvings <N> |
[method.dt_check].n_halvings |
if2, mh, nl-sbplx, nl-bobyqa, nl-lbfgs |
--dt-check-strict |
[method.dt_check].threshold_nats |
if2, mh, nl-sbplx, nl-bobyqa, nl-lbfgs |
--cooling-target-iters |
cooling_target_iters |
if2 |
--max-tree-depth <N> |
max_tree_depth |
pgas |
--trajectory-warmup <N> |
trajectory_warmup |
pgas |
--csmc-sweeps-per-nuts |
csmc_sweeps_per_nuts |
pgas |
--n-trajectories <N> |
n_trajectories |
pgas |
--diagonal-mass |
dense_mass = false |
pgas |
--no-nuts |
use_nuts = false |
pgas |
--no-ancestor-sampling |
ancestor_sampling = false |
pgas |
--binomial <SAMPLER> |
binomial |
pgas |
--no-adapt |
adapt = false |
pmmh, mh |
--adapt-start <N> |
adapt_start |
pmmh, mh |
--record-ancestry |
record_ancestry = true |
pfilter |
--record-prequential |
record_prequential = true |
pfilter |
The boolean overrides are one-way: they can switch a setting off, and switching it back means editing the TOML.
A flag the method reads is also refused when the method’s other settings leave it nothing to do: --max-tree-depth or --diagonal-mass with NUTS off (--no-nuts or use_nuts = false), --adapt-start with adaptation off (--no-adapt or adapt = false), --dt-check-halvings or --dt-check-strict with the dt-check off, and --dt-check-strict beside a declared [method.dt_check] threshold_nats.
Seeds and fits. A fit runs at a single base seed — --seed N, default 1 — and derives each chain’s seed from it by a golden-ratio mix, base ^ (chain_index · 0x9e3779b97f4a7c15), not by addition. For a start-sensitivity sweep, the fit.toml also accepts a top-level fit_seeds list; each entry becomes its own cell and its own leaf:
fit_seeds = [1, 2]━━━ cell 1/2: fit_seed=1 ━━━
stored if2 · …/if2-fee126b1/seed_1-e6a47b86/
━━━ cell 2/2: fit_seed=2 ━━━
stored if2 · …/if2-fee126b1/seed_2-f69dd668/
fit_seeds is stripped from the fit-level hash, so adding seeds extends a fit rather than re-keying it.
7.3 Sweep Semantics for Fits
--sweep NAME=SPEC overrides a parameter in [fixed] at each grid point. The swept parameter must be in [fixed]; both failure modes name the fix:
$ camdl fit run fits/01_mle.toml --sweep "beta=0.1,0.2"
error: cannot sweep 'beta' — it is in [estimate].
Sweeps override [fixed] parameters. Move 'beta' to [fixed] first.
$ camdl fit run fits/01_mle.toml --sweep "zzz=0.1,0.2"
error: sweep parameter 'zzz' not found in [fixed].
Available fixed params: N0, I0, rho, k
So a parameter promotes from fixed to swept with no config change:
[fixed]
rho = 0.6camdl fit run fits/01_mle.toml --sweep "rho=0.4,0.6"Repeating the flag takes the Cartesian product, and each point runs the method independently into its own content-addressed fit segment:
$ camdl fit run fits/01_mle.toml --sweep "rho=0.5,0.6" --sweep "k=5,10"
sweep: 4 points
═══ sweep point 1/4: rho_0.500__k_5.000 ═══
best ll=-59.9 (chain 1) in 8.6s
═══ sweep point 2/4: rho_0.500__k_10.000 ═══
best ll=-59.3 (chain 3) in 8.7s
═══ sweep point 3/4: rho_0.600__k_5.000 ═══
best ll=-59.6 (chain 3) in 8.8s
═══ sweep point 4/4: rho_0.600__k_10.000 ═══
Source convergence. A file that starts from a stored fit (starts = { from_mle = "@scout" }, { from_posterior = "@short" }, or the --starts override) is checked against that source’s stored verdict where the handle is resolved, before the model is loaded. An IF2 source is judged by its own gate — the tail chain-agreement  leg and the inter-chain log-likelihood spread in decibans — an nl-* source by the verdict its optimizer recorded at the end of its run (every chain converged, the chains’ estimates agree, and their log-likelihoods agree in decibans; a leaf written before that verdict was recorded is refused, and --force re-runs it), and a sampler source by the same R̂ classification fit summary renders. A source that did not converge is refused with the verdict, so a warm start cannot launder one fit’s multi-modality into the next:
$ camdl fit run fits/02_posterior.toml
error: starts source @scout: it did not converge.
Tail  (IF2 chain agreement over the last half of iterations), threshold 1.01:
✗ gamma  = 1.145 (>= 1.01)
beta  = 0.965
Loglik spread: 0.2 (best chain loglik -58.4)
Failing: gamma (Â=1.15)
Pick one:
- re-run it with more chains or iterations
- narrow bounds to the basin its best chain found
- fix a weakly identified initial-state parameter rather than estimate it
To start from it anyway (the starts may launder multi-modality):
camdl fit run fit.toml --allow-nonconverged-source
Every estimated parameter’s  is judged, initial-state (perturb_only_at_t0) parameters included, and an  that is not finite (within-chain variance collapsed) makes the verdict incomplete, which is refused like a failure: the decibans leg does not stand in for it, since chains spread along a ridge can agree in log-likelihood while disagreeing in θ. An IF2 source that lost started chains (n_good_chains < n_chains) is incomplete too, and refused with a message naming the count (5 of its 6 IF2 chains were lost …), what that leaves to judge, the remedy (re-run the source so every chain finishes) and the --allow-nonconverged-source override. When such a source also fails a leg, the failure is the verdict and the refusal adds the lost-chain count. A source that asked for one chain is accepted, as before.
--allow-nonconverged-source downgrades the refusal to a warning that repeats the verdict and says starting from it anyway. A source between the soft and hard agreement bands is accepted with a warning naming the parameters its chains only nearly agreed on. There is no gate after the run: a regression is visible in fit table, and the consumer’s own verdict is its own.
Under --sweep every point resolves the same source, so a refused source refuses the whole sweep before any point runs.
For a dedicated profile-likelihood workflow — fix a focal parameter, maximise over the rest at each grid point — use camdl profile, which runs IF2 per cell over its own --sweep grid.
8. fit.toml Examples
The four configs below were all executed end to end. They share one project layout; every path inside a fit.toml resolves relative to that file, which is why the configs in fits/ reach back out with ../.
models/sir.camdl # closed SIR, weekly NegBinomial reported cases
data/cases.tsv # time, weekly_cases
params/sir_fixed.toml # shared [fixed] block
fits/*.toml
results/ # output_dir
The iteration counts are deliberately small so each example finishes in seconds. Real fits want one to two orders of magnitude more of everything.
8.1 MLE with IF2
The minimum useful fit: an IF2 method maximising the stochastic-process likelihood.
output_dir = "../results"
[model]
camdl = "../models/sir.camdl"
[data.observations]
weekly_cases = "../data/cases.tsv"
[estimate]
beta = { bounds = [0.05, 1.0], start = 0.25 }
gamma = { bounds = [0.01, 0.5], start = 0.10 }
[fixed]
N0 = 10000
I0 = 10
rho = 0.6
k = 10.0
[config]
dt = 1.0
[method]
algorithm = "if2"
backend = "chain_binomial"
chains = 4
particles = 200
iterations = 10
cooling = 0.7$ camdl fit run fits/01_mle.toml --seed 1 --label mle
cached IR for sir.camdl (97c3d4c1)
fit: fits/01_mle.toml (method=if2)
model: fits/../models/sir.camdl
estimate: beta, gamma
fixed: N0, I0, rho, k
starts: uniform_unconstrained (one independent draw per chain) — default: no sampleable prior on beta, gamma
output: …/results/fits/01_mle-2030ba2b
── method: if2 ──
note: `starts = "uniform_unconstrained"` draws every chain's start, so `[estimate].start` is unused here for: beta, gamma. Use `starts = "single"` to start every chain at the declared values, or drop the `start` entries.
running 4 chains × 200 particles × 10 iterations, cooling=0.7, dt=1
transforms (chain 1 of 4; chains start at different points — see chain_starts.tsv):
beta log_interval [0.05, 1] start=0.3757 z=0.72 rw_sd=0.0335 (0.135/step, auto)
gamma log_interval [0.01, 0.5] start=0.1405 z=0.73 rw_sd=0.0138 (0.115/step, auto)
⚠ 2/2 parameters using auto rw_sd. Check traces and set explicit values.
cooling: cf50=0.70, reached at iter 50 (target), over a 10-iteration run × 12 observations
iter 1: rw_sd at 99.3%
iter 50: rw_sd at 70.0% (cf50 reached)
iter 10: rw_sd at 93.1% (run end)
evaluating loglik (every 10 iterations, all 4 chains)...
chain 0: ll=-58.4 chain 1: ll=-58.4 chain 2: ll=-58.6 chain 3: ll=-58.5
loglik-eval: re-scoring final-iter θ̂ (4 chains × 8 replicates @ 4000 particles)...
best chain: 1 (loglik=-58.38 ± 0.01)
chain clean logliks: [-58.5, -58.4, -58.6, -58.6]
Â:
beta Â=0.965 ✓
gamma Â=1.145 ~
dt-convergence at θ̂: PASS (|Δ_leg1| = 0.65, |Δ_leg2| = 1.16 nats (vs τ = 2.00); converged.)
── diagnostics ──────────────────────────────────────
i Auto rw_sd for 'beta': 0.033493.
i Auto rw_sd for 'gamma': 0.013831.
! 1/2 parameters have  > 1.1 (max 1.15).
0 error(s), 1 warning(s), 2 info
stored if2 · fits/../results/fits/01_mle-2030ba2b/if2-fee126b1/seed_1-e6a47b86/
best ll=-58.4 (chain 1) in 8.5s
Two things the run says that are worth reading. The starts line and its note: no parameter here declares a prior, so the default rule is uniform_unconstrained, which draws each chain’s start, and the start values in [estimate] are inert unless you set starts = "single". And the  block: chain agreement on gamma is 1.145, which at ten iterations means the chains have not agreed on a basin. camdl fit summary renders the same run as a verdict:
$ camdl fit summary results/fits/01_mle-2030ba2b
results/fits/01_mle-2030ba2b/
camdl 0.1.0+3e2b2888
══ if2 ═══════════════════════════════════════════════════════════════════════
seeded from: uniform_unconstrained (one independent draw per chain)
best loglik: -58.4 (if2) (loglik-eval, max across chains)
chains: 4
compound convergence gate
 leg: max  = 1.145 (gamma) ✗ (threshold 1.01)
decibans leg: Δ = 0.9 dB / threshold 30.0 dB ✓ (σ_max=0.02)
overall: ✗ FAIL
parameter estimates (loglik-eval, selected chain θ̂)
beta = 0.458330 Â=0.965 ✓
gamma = 0.263048 Â=1.145 ✗
per-chain loglik-eval (4 chains)
chain loglik ± se
1 -58.51 ± 0.02
2 -58.38 ± 0.01 ← selected
3 -58.57 ± 0.01
4 -58.59 ± 0.01
ESS at θ̂
min = 1772 (at obs step 1) mean = 3158
dt-convergence at θ̂ (Richardson)
verdict: PASS (|Δ_leg1| = 0.65, |Δ_leg2| = 1.16 nats (vs τ = 2.00); converged.)
provenance
final_params.toml ↔ mle_params.toml: ✓ params match
fit_state.toml ↔ final_params.toml: ✓ params match
The gate verdict here is informational — nothing consumes this fit. It becomes binding the moment another file starts from it (starts = { from_mle = "@mle" }, §8.2): that file is refused until the scout converges, or --allow-nonconverged-source is passed (§7.3).
8.2 MLE, posterior sampling, and a filter evaluation
A pipeline of three files: IF2 finds the basin, PGAS samples the posterior starting from it, and a particle filter re-scores θ̂ with a replicate spread. Each file carries one [method]; the second and third name the first by the label it was run with.
Priors are required for a sampler. They are externally-tagged inline tables keyed by the distribution name — prior = { log_normal = { mu = …, sigma = … } }, not prior = { dist = "log_normal", … }. A fit.toml prior overrides the model’s ~ <dist>(...) declaration; if neither is present, camdl warns and falls back to flat, and prior = { flat = {} } is how you ask for flat on purpose without the warning.
The problem half is the same in all three files; the scout is 01_mle.toml with priors added:
# fits/02_scout.toml
output_dir = "../results"
[provenance]
derived_from = "01_mle.toml"
reason = "priors, so PGAS can sample the basin scout finds"
[model]
camdl = "../models/sir.camdl"
[data.observations]
weekly_cases = "../data/cases.tsv"
[estimate]
beta = { bounds = [0.05, 1.0], prior = { log_normal = { mu = -1.0, sigma = 0.5 } } }
gamma = { bounds = [0.01, 0.5], prior = { log_normal = { mu = -2.0, sigma = 0.5 } } }
[fixed]
N0 = 10000
I0 = 10
rho = 0.6
k = 10.0
[config]
dt = 1.0
[method]
algorithm = "if2"
backend = "chain_binomial"
chains = 4
particles = 200
iterations = 10
cooling = 0.7The posterior and evaluation files repeat the problem half and change only [method]; camdl fit new --from fits/02_scout.toml fits/02_posterior.toml copies it. Because the problem is the same, all three land under one fit-level hash, in sibling segments named by their stems.
# fits/02_posterior.toml — [method] only; the rest as 02_scout.toml
[method]
algorithm = "pgas"
backend = "chain_binomial"
chains = 2
particles = 50
sweeps = 200
burn_in = 50
starts = { from_mle = "@scout" }# fits/02_evaluate.toml — [method] only
[method]
algorithm = "pfilter"
backend = "chain_binomial"
particles = 500
replicates = 5
starts = { from_mle = "@scout" }$ camdl fit run fits/02_scout.toml --seed 1 --label scout
$ camdl fit run fits/02_posterior.toml --seed 1
$ camdl fit run fits/02_evaluate.toml --seed 1
TOML ordering bites here: output_dir is a top-level key, so it must appear before the first [table] header. Written after [provenance] it is parsed as a provenance field and rejected:
error: error in fits/02_posterior.toml:
parse error: TOML parse error at line 5, column 1
|
5 | output_dir = "../results"
| ^^^^^^^^^^
unknown field `output_dir`, expected `derived_from` or `reason`
The PGAS file resolves its source first, then runs NUTS on θ given the conditioned trajectory:
starts source: …/02_scout-77595169/if2-fee126b1/seed_1-e6a47b86 (fit_state.toml)
── method: pgas ──
chain 0: NUTS enabled (gradient expressions found in IR)
chain 0: dense mass matrix estimated (sweep 35):
beta sd=0.017766
gamma sd=0.052177
correlations: bet-gam=0.42
chain 0: NUTS fully adapted (sweep 50):
final step_size: 0.172352
chain 0 done: 2.7s, acceptance: [beta=92%, gamma=92%]
chain 1 done: 2.7s, acceptance: [beta=90%, gamma=90%]
Rhat / ESS:
beta Rhat=2.065 ✗ ESS=NaN
gamma Rhat=1.675 ✗ ESS=NaN
draws.tsv: 60 posterior samples (all 6 params)
── diagnostics ──────────────────────────────────────
x parameter acceptance rate 92.5% is outside healthy range [15%, 50%].
x Rhat for 'beta' is 2.065 (threshold 1.1). Chain estimates have not converged.
6 error(s), 0 warning(s), 0 info
── method: pfilter ──
pfilter rep 1/5: loglik=-58.3
pfilter rep 2/5: loglik=-58.3
pfilter rep 3/5: loglik=-58.5
pfilter rep 4/5: loglik=-58.3
pfilter rep 5/5: loglik=-58.4
loglik = -58.4 ± 0.1 (5 reps, 500 particles, 0.2s)
prequential: elpd=-58.30, mean_crps=316.328, PIT 90% cov=0.08
R̂ of 2.07 at 200 sweeps is a not-converged posterior, reported as errors rather than buried — a real run needs thousands of sweeps. There is a second thing to read here: from_mle is a point rule, so both chains began at the scout’s estimate and the between-chain R̂ cannot say whether the posterior was explored. fit summary on this leaf prints seeded from: from_mle @scout, run <scout run id> (every chain at one point) and R̂ — not assessed: all 2 chains started at one point (starts = from_mle @scout) in place of a verdict (§6.6). To keep R̂ meaningful, start from the priors (drop starts; every parameter here has one) or from a short sampler run’s cloud (starts = { from_posterior = "@short" }). The pfilter file writes prequential.tsv and prequential.json; the JSON is what camdl compare reads to score fits against each other.
Beneath the prequential: line, whenever at least one observation was scored, the filter prints the surprise table: the five worst-scored observations of the trace, worst first (camdl pfilter --save-prequential prints the same table after its own prequential trace written: line). The elpd is a sum, and a sum hides which terms carry it; the table is the answer to “which observations did the model find hardest?” without opening prequential.tsv. From a five-patch spatial model with two streams bound:
worst-scored observations (5 of 53 scored; together 12.3% of elpd -562.3):
t stream y_obs pred q05–q95 log_score share pit ess
126 cases_p2 1088 [261, 844] -15.3 (-9.9) 2.7% 0.98 106.1
287 cases_p2 600 [182, 532] -13.8 (-8.1) 2.5% 0.96 143.7
119 cases_p2 411 [426, 1322] -13.5 (-7.4) 2.4% 0.11 168.8
238 cases_p2 258 [86, 232] -13.3 (-7.0) 2.4% 0.97 117.7
294 cases_p1 579 [178, 568] -13.2 (-7.5) 2.3% 0.88 156.6
Rows are ranked by the step’s joint log score; share is that score as a fraction of the elpd, and the header gives the five rows’ combined share. On a multi-stream model the joint y_obs would be a cross-stream sum, so each row names the step’s worst stream and shows that stream’s observed value, 5–95% predictive band and (in parentheses) its own log score — the row a reader can look up in the data. ess is the filter’s effective sample size at the step (see filter_ess under PGAS). Here the shares are flat at 2–3%, which is the healthy reading: no single observation dominates. A row carrying a tenth of the elpd by itself, with pit at 0 or 1 and ess in the single digits, is one observation the filter could not explain — check the data row at that t before anything else.
Once a sampler leaf exists, fit predict replays the posterior forward and writes the predicted-vs-observed pair:
$ camdl fit predict fits/02_posterior.toml --n-draws 20
fit predict: free_forward horizon — subsampling 20 of 60 posterior draws (raise with --n-draws)
fit predict: one_step horizon — subsampling 20 of 60 posterior draws (raise with --n-draws)
fit predict: horizon=free_forward+one_step(20 draws) treatment=posterior, 1 scenario(s) [fitted], 1 stream(s), 60 draws from pgas method 'pgas'
wrote fits/../results/fits/02_posterior-77595169/predict/<run_id12>/predictive/weekly_cases.tsv
wrote fits/../results/fits/02_posterior-77595169/predict/<run_id12>/predictive.json
wrote fits/../results/fits/02_posterior-77595169/predict/<run_id12>/observed/weekly_cases.tsv
wrote fits/../results/fits/02_posterior-77595169/predict/<run_id12>/observed.json
$ cut -f1-6 results/fits/02_posterior-77595169/predictive/weekly_cases.tsv | head -3
scenario time horizon treatment fit_rhat_max fit_ess_min
fitted 0 free_forward posterior 2.0652
fitted 7 free_forward posterior 2.0652
(cut keeps the excerpt narrow; the full row continues with the per-row rhat_mean/ess_mean/rhat_pred/ess_pred channels, n_draws, and the q05…q95 band.)
Current limitation. A
pfilterleaf has no row incamdl fit table, and no entry infit summary --format json’stable_rows; it is listed inleaves. The textfit summarystill renders the IF2 and PGAS leaves correctly.
8.3 Deterministic ODE backend
The same model, fitted against the ODE skeleton rather than the stochastic process: an NLopt optimiser for the MLE, then gradient-free Metropolis-Hastings for the posterior. camdl fit methods lists every supported (algorithm, backend) pair; an invalid pair is rejected at load.
This is a different statistical object — p(y | θ, ODE skeleton), not p(y | θ) — appropriate for equilibrium or large-population models where the particle filter is structurally redundant. The runtime says so for every method:
ℹ nl-sbplx (Subspace simplex; robust to boundary non-smoothness): deterministic MLE on the ODE-skeleton likelihood.
camdl computes p(y|θ, ODE_skeleton) under nl-sbplx, not the stochastic-process p(y|θ) IF2/PGAS/PMMH compute. In low-noise regimes the two converge empirically; verify rather than assume.
output_dir = "../results"
[model]
camdl = "../models/sir.camdl"
[data.observations]
weekly_cases = "../data/cases.tsv"
[estimate]
beta = { bounds = [0.05, 1.0], prior = { log_normal = { mu = -1.0, sigma = 0.5 } } }
gamma = { bounds = [0.01, 0.5], prior = { log_normal = { mu = -2.0, sigma = 0.5 } } }
[fixed]
N0 = 10000
I0 = 10
rho = 0.6
k = 10.0
[config]
dt = 0.5
[method]
algorithm = "nl-sbplx"
backend = "ode"
chains = 3
max_evals = 200# fits/05_ode_posterior.toml — [method] only; the rest as 05_ode.toml
[method]
algorithm = "mh"
backend = "ode"
chains = 2
iterations = 400
burn_in = 100
starts = { from_mle = "@ode-mle" }$ camdl fit run fits/05_ode.toml --seed 1 --label ode-mle
── method: nl-sbplx ──
⚠ Phase 1 typhoid validation passed; other model classes still gathering downstream feedback.
status: 1 converged, 2 max-eval, 0 failed (of 3)
chain-agreement: rel range = 49.10% bound | abs range = 3.698e-1 ✗
loglik-eval: Δ = 0.0 dB / threshold 30 dB ✓
wall: 0.11s (3 chains)
dt-convergence at θ̂: PASS (|Δ_leg1| = 0.00, |Δ_leg2| = 0.00 nats (vs τ = 0.50); converged.)
$ camdl fit run fits/05_ode_posterior.toml --seed 1 --allow-nonconverged-source
── method: mh ──
ODE marginal-likelihood check at base θ (deterministic)...
ODE log L = -57.8 (deterministic; no PF variance)
observations (1 stream):
✓ weekly_cases incidence(infection) NegBinomial
acceptance rates:
chain 0: 51.3% ~ high
chain 1: 51.7% ~ high
Rhat:
beta Rhat=1.105 ~ ESS=NaN
gamma Rhat=1.051 ✓ ESS=18
8.5 Comparing and deriving configs
Two read-side commands make a family of fits navigable.
fit diff reports what changed between two configs, in fit-semantic terms rather than as a text diff:
$ camdl fit diff fits/01_mle.toml fits/02_posterior.toml
diff: fits/01_mle.toml → fits/02_posterior.toml
Model:
(no model changes)
Data:
(no data changes)
Parameters:
beta: prior (none) → log_normal(mu=-1, sigma=0.5)
gamma: prior (none) → log_normal(mu=-2, sigma=0.5)
Method:
algorithm: "if2" → "pgas"
burn_in: null → 50
cooling: 0.7 → null
iterations: 10 → null
particles: 200 → 50
starts: null → {"from_mle":"@scout"}
sweeps: null → 200
fit new copies a config, stamps a [provenance] block onto it, and says how the new file can start from the source fit’s results:
$ camdl fit new --from fits/01_mle.toml fits/04_derived.toml
[provenance] derived_from = "fits/01_mle.toml"
hint: to warm-start the derived fit from fits/../results/fits/01_mle-2030ba2b, write under its [method]
starts = { from_posterior = "@<label>" } # one draw per chain
starts = { from_mle = "@<label>" } # every chain at its estimate
(a handle is @label, a fit-id prefix, or the leaf directory; `camdl list` shows them)
created fits/04_derived.toml
fit table renders every run under a results root, one row each — a problem folder holding several runs has several rows, sharing a fit_id and told apart by run (the run id’s first 12 hex), and every cell in a row is that run’s own:
$ camdl fit table results/fits
fit_id run label stem method methods converged best_ll ll_type age
---------------------------------------------------------------------------------------------------------------------------
649b2ecc ea2edd7cbeb3 <unlabelled> 09_pgas_only pgas pgas no — complete_data 4m
2030ba2b ec3425f877b3 mle 01_mle if2 if2 no -58.4 if2 11m
812b33c5 26529d207a4b <unlabelled> 08_holdoutfil… if2 if2 no -48.0 if2 13m
77595169 0c6804051770 scout 02_scout if2 if2 no -58.4 if2 23m
8e6e8964 f3c3c026516b ode-mle 05_ode nl-sbplx nl-sb… no -58.9 marginal 23m
Labels come from --label at run time or from the top-level camdl label afterwards, addressed by the run’s run_id (a label names one run, §6.8):
$ camdl label 4b1e0c9a2f77 mle
ok: label set to "mle" on ./results/fits/01_mle-2030ba2b/if2-6c1d8e02/seed_1-e6a47b86
Filters: --converged / --gate-failed, --with-method if2|pgas|pmmh, --model <hash-prefix>, --hash <hash-prefix>, --since-seconds <n>, --label-pattern <glob>. Formats: text (default), json, md, csv. --quantity <name> adds a column carrying the posterior median of a scalar generated quantity, deriving it on demand for a fit that has not been predicted yet.
Note that fit_id is the fit-level hash — model, data, [estimate], [fixed] — computed with method, fit_seeds and output_dir deliberately excluded. Two configs that differ only in [method] therefore share a fit_id, and --hash <prefix> can return more than one row; the directory stem is what separates them.
9. Provenance and Cache Invalidation
9.1 The identity model
Every artifact camdl writes — a forward trajectory, a fit’s method leaf, a particle filter evaluation, a survey, a profile grid point, a multi-cell ensemble — is stored as a content-addressed leaf in one store rooted at the output directory (default results/, see run_paths::DEFAULT_OUTPUT_ROOT). There is no second scheme: fits are content-addressed exactly like simulations. The only thing that is “named” about a fit directory is the human-readable label in each path segment, which is provenance and never identity.
A leaf’s identity is not one flat hash. It is an ordered tuple of per-level content hashes, and the leaf address is derived from that tuple:
run_id = SHA256( HASH_VERSION:u16 ++ kind_tag:u32 ++ n_levels:u64 ++ level_hash[0..n] )
(runid::kind::run_id, rust/crates/runid/src/kind.rs). kind_tag is the ArtifactKind declaration index (Sim=0, FitMethod=1, Pfilter=2, Survey=3, ProfilePoint=4, Obs=5, Projection=6, SimEnsemble=7), so two kinds with a coincidentally-equal level sequence cannot alias.
Each level hash is the ContentAddressed::content_hash of a resolved input slice, itself SHA256(HASH_VERSION ++ canonical_bytes). The store path is a readable factoring of that tuple: one directory segment per level, each {label}-{hash8}, where hash8 is the first 4 bytes of the level hash as 8 hex characters and the label is cosmetic (runid::layout). Navigation and every read path resolve through run.json, never through path segments.
Two rules govern what may enter a hash (rust/crates/runid/src/lib.rs, “Adding a field to identity”):
- A field that changes the stored bytes is identity and must re-key.
- A pure re-encoding of the same values is presentation and is stripped.
A missed re-key silently serves a stale result for different inputs. That is the worst failure this subsystem can produce, and it is worse than an over-invalidation.
9.2 What enters a run’s identity
9.2.1 The canonical hasher
One pinned function and encoding, in rust/crates/runid/src/hash.rs:
- SHA-256, recorded by
HASH_VERSION(currently1), folded into every root hash so the whole store migrates on a single bump. - Domain separation. Every named type writes its fully-qualified type name (length-prefixed) then its
SCHEMA_VERSIONbefore any field. - Length prefixing. Every variable-length value (string, byte slice,
Vec, map, set) writes au64LE element count first. - Primitives. Fixed-width little-endian integers;
boolas one byte;charasu32. - Floats, two policies. Resolved user inputs go through
FiniteF64, which rejectsNaN/±Infat construction and normalizes-0.0 → +0.0. Structural IR floats are hashed as rawf64::to_bits, keeping±0.0and NaN payloads distinct to match the IR’s ownConstExpr::PartialEq. Baref64is deliberately notContentAddressed, so a field cannot silently pick up the wrong policy. - Maps and sets iterate in sorted key order, count-prefixed.
Optionis a0/1tag byte then payload. Enums write the variant index asu32LE in declaration order, then the payload.
9.2.2 Include-by-default, with one explicit escape
#[derive(RunInput)] (rust/crates/runid-derive/src/lib.rs) folds every field of a struct in declaration order. There is no skip-if-default: adding a field re-keys every existing leaf of that kind even at its default value, and that turnover is intended, not a bug to engineer around. Two attributes exist:
#[run_input(provenance)]on a field — excluded entirely (recorded inrun.json, never hashed).#[run_input(schema_version = N)]on the type — bumps only that type’s key.
A field whose type is not ContentAddressed is a compile error, so an input cannot be made unhashable by accident.
The fit level takes the other path: FitConfigV2 is serde-only and enters identity as the digest of its key-sorted canonical JSON, so #[serde(skip_serializing_if = …)] controls hash membership there — a default-skipped field stays out of the hash.
9.2.3 The model digest
ModelDigest::from_model (rust/crates/runid/src/inputs.rs) is the single constructor every identity path uses:
| field | value |
|---|---|
ir |
model_ir_hash(model) — the whole canonical IR |
ir_version |
the ir/VERSION string (e.g. "0.42") |
engine |
EngineVersion = "{CARGO_PKG_VERSION}+{git-hash}" |
model_ir_hash applies normalize_for_hash, which blanks the two pure-presentation IR fields — output.format and simulation.time_semantics — before hashing, so a rendering choice never re-keys a run. The normalization lives inside the constructor, not at any call site, so no artifact kind can opt out of it.
The IR walk is hand-written (rust/crates/runid/src/ir_hash.rs, hashing-policy schema version SV, currently 4). Model::hash_into folds, in order: version, time_unit, description, origin, origin_rata_die, compartments, transitions, ode_equations, time_functions, tables, interventions, observations, parameters, constants (the typed let constants, whose values are identity), bindings, per_eval_bindings, initial_conditions, output, simulation, presets, model_structure, balance, identity_tracked_compartments. Every field is folded unconditionally: a value’s presence or non-default status is never what is hashed, only the value.
The ir::Model fields below are deliberately not hashed:
name—camdlcsets it to the source file’s stem, so it is a label. Two byte-identical models under different file names are the same model and share arun_id.ic_gradand every compiler-derived gradient map (rate_grad,rate_state_grad,sigma_sq_grad,projection_state_grad, each obsDiffable’sgrad/proj_grad) — deterministic autodiff of already-hashed rates over already-hashed parameters, so model identity is gradient-independent and a--no-state-gradcompile keys the same.quantitiesandcontrasts— reporting-only reductions written to a regenerated sidecar outside the leaf (§10.8), never to the leaf itself.
Within SimulationConfig::hash_into the integrator is always folded: a variant tag, then for Rk45 each tolerance as an Option, so RK4, RK45 at the runtime default tolerances, and each explicit tolerance hash distinctly. --integrator changes the resolved model’s integrator, so the two settings key as two different models (the model level; config is unchanged).
9.2.4 The levels, per artifact kind
kind (store_dir) |
levels, in path order | resolver |
|---|---|---|
Sim (sims/) |
model · config · params · scenario · seed | cli/src/resolve.rs |
SimEnsemble (ensembles/) |
model · config · params · grid | cli/src/sim_ensemble_cas.rs |
FitMethod (fits/) |
fit · method · seed | cli/src/fit/cas.rs |
Pfilter (pfilters/) |
model · config · params · seed | cli/src/pfilter_cas.rs |
Survey (surveys/) |
model · config · box · seed | cli/src/survey_cas.rs |
ProfilePoint (profiles/) |
profile · point · method · seed · start | cli/src/profile_cas.rs |
Sim levels (resolve::resolve_trajectory; the level types are in runid::inputs):
model—ModelDigest(§9.2.3). Label: the model file stem.config—SimConfig,schema_version = 5:backend(gillespie/chain_binomial/ode),dt,t_start,t_end, the resolved output schedule (Regular { start, step }orAtTimes([…])),calendar(a placeholder alwaysNumerictoday),allow_degenerate_rates,no_flows, thecolumnsallow-list as a sorted set, andinit_state. The schema version is also where the leaf’s file format enters the key: version 5 changed no field, it re-keyed every sim leaf becausetraj.tsvgainedt_start/t_stop(§10.1) and a key must not name bytes a recompute no longer reproduces. Label:{backend}-dt{dt}.params—ResolvedParams: the resolved base parameter map (BTreeMap<ParamId, FiniteF64>, i.e. compiled-in values overlaid by--paramsthen--param, plus any sweep point) and the SHA-256 content digests of every--table NAME=FILEfile, sorted by table name. Files are hashed by bytes, never by path. Label:base, ak=vjoin of the sweep point, ordrawswhen a full draw vector would overflow the path component.scenario—ResolvedScenario: the sortedenableanddisableintervention-id sets and the parameter patch. Only the delta; renaming a scenario preserves the hash. Label: the scenario name.seed—Seed. Onlyprocess_seedis hashed;base_seedis#[run_input(provenance)]. Hashing the derived per-cell seed rather than the user’s--seedis what stops a lone run and a sweep cell from aliasing. Label:seed_{n}— note this renders the process seed, so a multi-replicate run shows segments likeseed_5871781006564002452.
FitMethod levels (fit::cas::resolve_fit_method):
fit—FitDigest: theModelDigest; the content digest of each resolved training stream; the content digest of each[data.holdout]stream; the digest of the whole canonicalized fit.toml lessmethod,fit_seedsandoutput_dir; and the engine version. The fit level hashes the problem alone, so two files that differ only in[method]share it. Model and data paths stay in the blob — a rename over-invalidates harmlessly.method—MethodLevel=MethodConfigfolded withdeps.MethodConfigcarries the digest ofMethod::identity_payload()— the algorithm block and the resolvedstartsrule — re-augmented withn_trajectories, plustarget_lengthand the resolved (not requested) observation-time alignment (ExactvsSnap). Foldingdepshere is what makes a posterior file re-key when the scout itsstartsnames changes. The label is the algorithm’s name.seed— the resolved fit RNG seed.
Method::identity_payload() deliberately omits the extension dimension (PGAS sweeps, PMMH iterations) so --resume can lengthen a chain without re-keying; the resume length is keyed separately as target_length.
SimEnsemble levels: the config level is only {backend, dt}; every other config knob reaches the ensemble through the grid level, which digests {n_cells, [(scenario_label, process_seed, draw_idx, sim_run_id)] sorted}. The cell count is in the key explicitly.
Pfilter config digests {particles, replicates, dt, obs_block, flow_indices, data} where data is the sorted per-stream file digests; params digests the sorted scored point. Scenario selection reaches identity through the model digest, because the pfilter path hashes the resolver’s model (scenario already applied), unlike Sim.
Survey config digests {eval_method, eval_particles, eval_replicates, data, fixed, scenario}; box digests {bounds, n_points}.
Lineage. deps is a set of ArtifactRef { run_id, kind, artifact, digest } hashed sorted by run_id (the one documented exception to the order-sensitive Vec rule), with kind provenance-only. Folding the consumed file’s digest as well as the producer’s run_id means a regenerated upstream re-keys the consumer while a change to an unrelated sibling artifact does not.
9.2.5 Non-finite floats cannot collide
serde_json renders NaN/±Inf as null in both to_value and the text serializer without erroring, so two configs differing only in a non-finite float would hash equal. Every JSON-digested identity blob is therefore passed through fit::cas::ensure_finite first, a Serializer that produces nothing and fails on any non-finite float anywhere in the value. On the typed path the same job is done by FiniteF64’s constructor.
9.3 Store layout and cache semantics
<root>/
index.json # derived, rebuildable (§9.6)
.staging/ # in-flight commits
.quarantine/ # unidentifiable debris moved aside
sims/
{model_stem}-{h8}/
{backend}-dt{dt}-{h8}/
{param_label}-{h8}/
{scenario}-{h8}/
seed_{n}-{h8}/
run.json # RunRecord, kind = sim
traj.tsv
event_log.tsv # only with --event-log
reactive_log.tsv # only when a reactive policy fired
obs/ # declared child, one dir per (obs model, obs seed)
{obs_hash8}-{obs_seed}/
<stream>.tsv
obs.json # NOT a RunRecord
ensembles/
{model_stem}-{h8}/{backend}-dt{dt}-{h8}/{param_label}-{h8}/cells-n{N}-{h8}/
run.json
ensemble.tsv
fits/
{fit_stem}-{h8}/ # the fit level; also holds fit-wide sidecars
fit.meta.json
fit.toml.original
model.camdl.original
model.ir.json
model.graph.json
model.render.json
{method}-{h8}/
seed_{n}-{h8}/
run.json # RunRecord, kind = fit_method
… method artifacts (§10.6)
pfilters/ surveys/ profiles/ # same {label}-{h8} factoring per §9.2.4
A path component that would exceed NAME_MAX is truncated to 200 bytes and suffixed ..{hash16} of the full label; identity is unaffected because the label is never hashed.
Lookup (runid::store::FsCasStore::lookup) is tiered and returns one of:
| outcome | meaning |
|---|---|
Hit(record) |
identity matches, Completed, exact-set integrity ok |
Miss |
no run.json at the path |
Stale(Incomplete) |
status != Completed (crashed or in-flight) |
Stale(Corrupt) |
unparseable run.json, or a listed file missing / wrong size or mtime |
Stale(OrphanFiles) |
an unlisted, undeclared file or subdirectory is present |
Stale(SchemaDrift) |
hash_version or format_version is not current |
Collision(record) |
a different full identity occupies this path (short-hash clash) |
A Collision is never touched: the writer escalates the final segment ({seg} → {seg}~{hash16} → {seg}~{full64}) until it finds a free candidate, and the reader enumerates sibling directories rather than reconstructing names.
The integrity gate is bytes + mtime per manifest entry. It is a cheap gate — cp without -p, an unzip, or a checkout will change mtimes and make every leaf read Stale, and conversely a file edited in place to the same length with the mtime restored passes. The strong digest recorded per artifact is not recomputed on any read path today.
What a cache hit actually saves.
- A multi-cell
camdl simulateskips the simulation itself:SimSinkdefers toCasSink::should_run, which returnsfalseon a hit under the exact rule of §4.4, and the cell’straj.tsv(and stored observations) are read back for the combined outputs instead. - A lone
camdl simulaterecomputes its cell. What the hit saves is the write:commit_atomicsees anAlreadyCompletedleaf and discards the freshly staged bytes (unless--force, below).
Consequences of that second rule, which are load-bearing for anyone reasoning about the store:
- On a hit the leaf keeps the bytes of the first writer. A second run whose identity matches but whose bytes differ leaves no trace, unless it is forced.
simulate --forcequarantines an existing same-identity leaf (moved to<root>/.quarantine/, not deleted) and stores the recomputed bytes in its place, so a forced run is the one way to replace a leaf’s contents.- An artifact that only some invocations produce (
event_log.tsv) cannot be added to a leaf that already exists without a fresh identity or a manual delete; call sites check for the file’s existence rather than assuming it. --labelis the one recorded field refreshed on a hit: the leaf’srun.json.provenance.labelis rewritten in place when it differs.
What re-keys a Sim run (verified by perturbing one input at a time against ocaml/golden/sir_basic.camdl):
| change | re-keys? | level |
|---|---|---|
| model file edit | yes | model |
| camdl (engine) version | yes | model |
ir/VERSION |
yes | model |
--backend |
yes | config |
--dt |
yes | config |
--output-every |
yes | model1 |
--no-flows, --columns |
yes | config |
--allow-degenerate-rates |
yes | config |
--param, --params |
yes | params |
--table file contents |
yes | params |
--scenario, --enable, --disable |
yes | scenario |
--seed, --seeds, --replicates |
yes | seed |
--emit-every |
no2 | obs child |
--label |
no | provenance |
-o, --obs*, --draws-out |
no | mirrors only |
--integrator |
yes | model |
--dates |
no | mirrors only |
9.4 Fit identity and re-running a changed fit
There is no staleness error. A fit whose config changed simply resolves to a different run_id, lands in a different leaf, and runs; the previous leaf stays addressable. There is no provenance.json, no stored config_hash compared on re-run, and no --force prompt on the fit path.
What each edit does:
| edit | effect |
|---|---|
| model, training data bytes, holdout data bytes | new fit level → the method re-runs |
any fit.toml field outside method/fit_seeds/output_dir |
new fit level → the method re-runs |
a [method] field, starts included |
new method level for this file only; siblings reused |
a sourced start’s source (from_mle, from_posterior, from_params) |
new deps → new method level for the consumer |
fit_seeds |
new seed level |
output_dir |
nothing (write location is provenance) |
Chain-start sources are keyed by content, not path: a from_posterior source’s draws.tsv, a from_mle source’s fit_state.toml and a from_params TOML each enter deps as their file digest, so rewriting the file in place re-keys the fit.
--resume is guarded by the run’s own identity, less its length. A resumed PMMH or PGAS run (the two methods --resume accepts) extends its base’s chains, so it lands in a leaf of its own: its method level carries the new target length and a dep on the base. That dep, cas::cas_resume_dep, is the base leaf’s run_id plus one digest over every chain’s carried files in chain order (artifact chain_*/{resume_state.bin,trace.tsv,trajectories.tsv}): the chain state the resume continues from and the rows the resumed leaf copies, so a base whose rows differ is a different base. It is not the base’s fit_state.toml, which records when the base finished, so two runs of one fixed-seed base address one resumed leaf (gh#1015).
Continuing a base’s chain state is sound only if every other input is the one the base ran under. The key that checks this, cas::resume_guard, hashes the same three levels (fit / method / seed) as the leaf’s run_id, with the target length set to zero and the method level’s deps taken without the resume base’s dep, so only a sourced start’s dep remains. Each chain’s resume_state.bin records the key as config_hash, and a resume refuses a chain whose recorded key differs:
error: config hash mismatch for chain 0 — cannot resume. Either the
model/data/priors changed since the original run, or this chain predates a
camdl version that changed how a method's identity is computed (…). Re-run
from scratch with --force.
Because the key is the run’s identity, it covers every input that re-keys a fit ([config] with its dt, the scenario, ic_free, the holdout, n_trajectories, the resolved observation alignment) and nothing else.
9.5 run.json — the per-run metadata contract
run.json is the API between camdl and everything that reads its output (camdl list/show/cat, viewers, notebooks). The on-disk schema is runid::record::RunRecord (rust/crates/runid/src/record.rs); FORMAT_VERSION is 1. The record itself is never hashed — identity comes only from the levels — which is why declaring an obs/ child cannot change the trajectory’s run_id.
A real sims/ leaf record (hashes and paths abbreviated):
{
"format_version": 1,
"kind": "sim",
"run_id": "467417eb…0258",
"hash_version": 1,
"ir_version": "0.30",
"engine_version": "0.1.0+<git>",
"levels": [
{
"name": "model",
"label": "sir_basic",
"hash": "cd37d79d…32e0",
"schema_version": 1
},
{
"name": "config",
"label": "chain_binomial-dt1",
"hash": "9fe90ef5…3c4f",
"schema_version": 1
},
{
"name": "params",
"label": "base",
"hash": "39391d3f…4ba3",
"schema_version": 1
},
{
"name": "scenario",
"label": "baseline",
"hash": "812a8a17…5b96",
"schema_version": 1
},
{
"name": "seed",
"label": "seed_1",
"hash": "e6a47b86…c5b4",
"schema_version": 1
}
],
"status": "completed",
"artifacts": {
"traj.tsv": {
"bytes": 1537,
"mtime": "1786506768.083634697",
"digest": "164a63c1…25f7"
}
},
"output_schema": {
"traj.tsv": {
"role": "trajectory",
"columns": [
{ "name": "t", "role": "time" },
{ "name": "t_start", "role": "window_start" },
{ "name": "t_stop", "role": "window_stop" },
{ "name": "S", "role": "state" },
{ "name": "I", "role": "state" },
{ "name": "R", "role": "state" },
{ "name": "flow_infection", "role": "flow" },
{ "name": "flow_recovery", "role": "flow" }
]
}
},
"provenance": {
"argv": [
"<camdl>",
"simulate",
"<model>.camdl",
"--scenario",
"baseline",
"--seed",
"1"
],
"created_at": "2026-08-12T03:52:48Z",
"camdl_version": "0.1.0+<git>",
"source_paths": ["<model>.camdl"]
}
}A fits/ method leaf adds deps (when its starts consumed a source), nests artifact keys (chain_0/trace.tsv), keys per-chain schema entries under a {n} wildcard, and carries a populated inputs. A fit leaf’s inputs are written with its claim — what the run was asked to do — so a failed or running leaf carries them too; finalize adds the three only the run can tell (best_loglik, best_chain, wall_time_seconds):
"inputs": {
"algorithm": { "algorithm": "if2", "backend": "chain_binomial",
"chains": 2, "cooling": 0.7, "iterations": 8, "particles": 1000 },
"backend": "chain_binomial",
"best_chain": 1,
"best_loglik": -163.87886531306089,
"chain_starts_kind": "spread",
"config_identities": ["3f0c9e1d…77b2"],
"fit_hash": "7aac3a33…a90a",
"method": "if2",
"n_chains": 2,
"seed": 1,
"starts": "from_prior",
"starts_source": null,
"wall_time_seconds": 25.255000542
}inputs.config_identities holds the canonical identity of each fit.toml the run was written from or reused by (a cache hit from a second config with the same run identity adds its own); a fit.toml handle names the run whose list holds the file’s identity (§2.2.1). A config is listed only on the run the file alone makes: fit run recomputes, per cell, the run_id that fit run <file> with no options would key (the file’s own method and starts source, the problem before --sweep) and lists the config only when it equals the run’s own. Every identity-bearing option — --starts, a sampler flag, --decibans-thresh, --sweep, --resume — therefore makes a run the file does not name; so do a --seed outside the file’s seeds and --emit-every, which changes the [synthetic] data generated before the identity is taken. inputs.starts_source is null unless starts read a source, and then {"handle": "@scout", "run_id": "<64-hex>", "artifact": "fit_state.toml"}: the handle as written and the run it resolved to when this run started (run_id is null for a file named directly). The source’s run_id is also this run’s deps entry, which is what keys it.
inputs.best_chain is the chain number that produced inputs.best_loglik — the number of a chain_N/ directory on this leaf, the same value the leaf’s fit_state.toml stores and the method’s own stderr line prints, so chain_<best_chain>/ opens with no arithmetic. It is null for a method with replicates rather than competing chains (pfilter). camdl show and camdl list --format json print it as recorded.
A resumed leaf. A --resume run’s leaf is the complete artifact for the longer chain; no reader follows the lineage back. Before the new segment runs, each chain_N/resume_state.bin, chain_N/trace.tsv and (PGAS, when the base saved paths) chain_N/trajectories.tsv is copied from the base with std::fs::copy — a copy-on-write clone on APFS, a copy_file_range (a reflink where the filesystem supports one) on Linux — and the new segment’s rows are appended to it. The index (step for PMMH, sweep for PGAS) continues from the base’s: the new segment’s first row is one past the base’s last, never a repeat of it and never a restart from 0, and fit run refuses a trace whose index does not increase strictly. Everything a reader takes as the posterior is over the whole chain: draws.tsv (PGAS keyed by sweep, the base’s draws first in each chain), the summary’s R̂ and ESS, trajectories.json’s n_draws and the summary’s saved-path counts, latent_convergence, PMMH’s acceptance rate and MAP, PGAS’s NUTS divergence counts, and fit_state.toml‘s best (PGAS takes the better of the base’s best and the new segment’s). ESS per second divides by the segments’ summed wall time.
fit_state.toml’s acceptance_rate is the best chain’s over its post-burn-in steps or retained sweeps. PMMH’s covers the whole chain, its accept count carried in the chain state. PGAS’s is the count-weighted combination of the base’s recorded rate, over the chain’s retained sweeps below the boundary, and the new segment’s — exact, because both are means over sweeps. The base records its best chain’s rate only, so when the resumed leaf’s best chain is another chain, PGAS’s rate covers the new segment’s sweeps alone.
Three PGAS outputs are computed from per-sweep sampler internals that the leaf does not keep for the base’s sweeps, and cover the new segment’s retained sweeps only: the path_renewal and filter_ess summary blocks with filter_ess.tsv, and the conditional-SMC findings in diagnostics.json (degenerate_ancestor_sampling, low_trajectory_renewal, filter_weight_collapse, path_renewal_coalesced, filter_starved). The cost block of either summary is this execution’s.
inputs.resume records the lineage, and is absent on a fresh run:
"resume": {
"parent_run_id": "f2aea3c8…ad4f",
"index_column": "step",
"segments": [
{ "run_id": "f2aea3c8…ad4f", "first_index": [0, 0], "wall_time_seconds": 0.257 },
{ "run_id": "2673d934…e150", "first_index": [60, 60], "wall_time_seconds": 0.252 }
]
}segments lists every run that contributed rows, oldest first, ending with this leaf’s own; first_index is indexed by chain number. A row of chain c whose index is i — index_column in chain_c/trace.tsv, draw in trajectories.tsv, the sweep in PGAS’s draws.tsv — belongs to the last segment whose first_index[c] <= i, so the last segment’s first_index is the resume boundary. A leaf resumed from a resumed leaf copies its parent’s segments forward. PMMH’s draws.tsv carries no step: draw k of chain c is the k-th retained row of chain_c/trace.tsv, which carries it.
Fields.
| field | type | meaning |
|---|---|---|
format_version |
u16 | run.json schema version; a mismatch is Stale(SchemaDrift) |
kind |
snake-case ArtifactKind |
sim, fit_method, pfilter, survey, profile_point, obs, projection, sim_ensemble |
run_id |
64-hex | the leaf address; the identity gate on every lookup |
hash_version |
u16 | HASH_VERSION of the encoding that produced the hashes |
ir_version |
string | ir/VERSION at write time (e.g. "0.30") |
engine_version |
string | "{pkg_version}+{git-hash}" |
levels |
[{name, label, hash, schema_version}] |
the factored identity in path order |
deps |
[{run_id, kind, artifact, digest}] |
consumed upstreams; omitted when empty |
status |
"running" | "completed" | "failed" |
a plain string, not an object |
failure |
{message, operation} |
on a "failed" record camdl stopped: its error, and what was running; omitted otherwise |
artifacts |
map path → {bytes, mtime, digest} |
EXACT SET over the leaf’s own files; mtime is "{secs}.{nanos:09}" |
output_schema |
map path → {role, columns} |
declared column roles (§10.9); omitted when empty |
children |
map namespace → [hash] |
declared child sub-artifact dirs (obs); omitted when empty |
inputs |
arbitrary JSON | display/audit summary, never hashed; omitted when null |
provenance |
object | argv, label, created_at, camdl_version, source_paths — all recorded, never hashed |
status transitions depend on the write mode. sim and sim_ensemble commit atomically: the leaf appears only once complete, and there is no discoverable in-flight record. fit_method, pfilter, survey and profile_point stream: they claim the directory, write a "running" record and an exclusive .lock, stream output in, then finalize to "completed" with the exact-set manifest and the post-run inputs. A run camdl stops on an error records "failed", with its failure, before it exits, and releases the .lock; a "running" record left behind is what a killed or crashed run leaves, and stays "running" (§2.3.1, “A run’s end is recorded, never inferred”). A stale .lock whose PID is dead is reclaimed, the prior attempt’s record preserved under .attempts/; a live one refuses with fit in progress.
Three provenance fields — finished_at, host, thread_count — exist in the schema and are never populated by any writer.
Sub-artifacts that are not RunRecords:
obs/{obs_hash8}-{obs_seed}/obs.json—{obs_hash, obs_seed, process_seed, streams, version}, whereobs_hashis the SHA-256 of the model’s serializedobservationsblock. The parent declares this directory underchildren.obs, but the declared hash isSHA256("{run_id}:{obs_seed}:{obs_hash}")and appears nowhere inside the child.fits/{fit_stem}-{h8}/fit.meta.json— the fit-wide sidecar:{schema_version, source_model, model_identity, source_config, fit_toml_hash, data_hashes, estimated, fixed, resolved_priors, parameters_provenance, schema}, whereschemadescribes each observation stream (name,index_dims,value_column,value_kind,likelihood).
9.6 The derived index (index.json)
<root>/index.json caches run_id → (rel_path, kind, label, status, created_at) so prefix resolution in show/cat does not re-walk the tree. It is versioned (INDEX_VERSION = 1), written with the same atomic tmp+rename+fsync ordering as run.json, and never authoritative:
- A lookup the index cannot satisfy falls back to a full walk and refreshes the index from it, so a leaf written out of band is still found.
- Every index hit is verified against the live tree — the entry’s
run.jsonis re-read and itsrun_idre-checked. A dead or re-identified entry is dropped and the lookup falls through to the walk.
A malformed, absent, or version-mismatched index.json is a clean miss, never an error. camdl dev reindex [ROOT] rebuilds it from a fresh walk.
9.7 Enumerating a grid — no manifest file
There is no grid-level manifest.json. Each completed cell is independently a content-addressed run.json leaf under sims/ (the system of record). To enumerate a grid, walk those leaves or read the derived index.json (§9.6). camdl list does this live; simulate --dry-run re-plans the grid from its flags and reports, per cell, whether the store already holds it.
A multi-cell simulate additionally writes one SimEnsemble leaf holding the combined wide-format TSV, with a deps edge to each cell’s traj.tsv.
9.8 Identity boundaries worth knowing
Two flags are easy to misjudge, and neither is a gap.
--integrator rk4|rk45(Sim) changes the trajectory and re-keys themodellevel: the override is part of the resolved model whose digest names the leaf, and the integrator is always folded into that digest (§9.2.3). Two integrators are two leaves.--dates(SimEnsemble) changes only how the combined TSV is rendered. The storedensemble.tsvnever carries date columns; they appear in the-oand--stdoutviews only (§10.2). The flag is therefore not identity, and a run with and without it is the same ensemble.
Related but sound: event_log.tsv and reactive_log.tsv are identity-free extra artifacts, so a leaf committed without them cannot gain them later. Call sites check for the file rather than assume it.
10. Output File Schemas
Every tabular output is tab-separated. The column roles of the files a run writes are declared in run.json.output_schema (§10.9), read back from the file’s own header, so a consumer never has to reverse-engineer a column name.
10.1 Trajectories (traj.tsv)
The canonical per-cell trajectory artifact in a Sim leaf. One row per output time. No comment or version line — the first line is the header:
t t_start t_stop S I R flow_infection flow_recovery
0 0 0 990 10 0 0 0
1 0 1 989 9 2 1 2
Columns, in this order (util::TRAJ_TIME_HEADER, then util::TrajColumns::select):
t— the output time, rendered with{}on anf64(0,1,0.5). The compartment columns are read at this instant.t_start,t_stop— the half-open period[t_start, t_stop)the row’sflow_*columns were accumulated over:t_stopis the row’st,t_startthe previous row’s. The initial-condition row has no interval before it and writes the empty[t, t); its flows are zero. When the output schedule has no time at the run’s start (anat = [...]list without it) there is no initial row, and the first row’st_startis the run’s start. A row carries both readings — a state att, a flow over the period — and names each boundary rather than leaving the period to be inferred from the row above.- Integer compartments, in model declaration order, rendered
{}. - Real compartments, in model declaration order, rendered
{:.4}. flow_<transition>for every transition, in model declaration order — integer flows{}, real flows{:.4}. Each value is the flow accumulated over[t_start, t_stop).
--no-flows drops group 5; --columns A,B,… restricts groups 3–5 to an allow-list validated against the model (an unknown name is a hard error listing the valid ones); the three time columns are always written. Emitted order always follows the model, never the allow-list. Both knobs are identity (§9.2.4), so a filtered trajectory is its own leaf.
The leaf trajectory never carries a date column, regardless of --dates.
10.2 Combined trajectory views (ensemble.tsv, -o, --stdout)
A multi-cell simulate (--replicates / --seeds / multiple --scenario / --draws) interleaves every cell into one wide TSV. The same table is the ensemble.tsv artifact of the SimEnsemble leaf and the -o PATH mirror. The two are byte-identical except for the date columns below: --dates renders into the -o mirror only, and the stored ensemble.tsv never carries them.
# 0.1.0+<git> (<build date>)
replicate t t_start t_stop date date_start date_stop S I R V flow_infection flow_recovery
0 0 0 0 2020-01-01 2020-01-01 2020-01-01 9990 10 0 0 0 0
0 1 0 1 2020-01-02 2020-01-01 2020-01-02 9985 13 2 0 5 2
- Line 1 is a
# {camdl version} ({build date})comment — present here and absent fromtraj.tsv. - Leading key columns appear only when the corresponding axis has more than one level:
replicatewhen the run has more than one cell,scenariowhen more than one scenario,drawwhen more than one parameter draw. replicatenumbers the cells of the whole run, 0-based in canonical order (scenario, then draw or design point, then replicate), so it runs on across scenarios: with 2 draws × 2 replicates, the first scenario’s rows carryreplicate0–3 and the second’s 4–7. It is not a key for pairing scenarios; a join on it matches nothing across scenarios. Cells of different scenarios at the same draw and the same replicate within that draw share a process seed (§3.1, the scenario index is absent from the mix), and those are the pairs to difference: join ondraw(or, for a sweep,point_idandseed) andt, plus, when there is more than one replicate per draw, the replicate’s position within its scenario,replicate mod (draws × replicates).date,date_start,date_stopappear only under--dates(in the-omirror, never in the storedensemble.tsv), which requires the model to declare anorigin; each is its numeric twin (t,t_start,t_stop) rendered viair::caltime::internal_to_date_hires.- The data columns are identical to §10.1 and honour the same
--no-flows/--columnsfilter.
A single-cell simulate writes no ensemble leaf; the one Sim leaf is the whole result.
10.3 Posterior draws (draws.tsv)
Written by the samplers at the end of a run, into the method leaf. IF2 and the optimizers write none.
PGAS and PMMH write all model parameters — estimated first in [estimate] order, then every remaining model parameter as a constant column — behind two key columns:
chain draw beta gamma rho k N0 I0
0 500 3.41…e-1 1.02…e-1 6.00…e-1 1.00…e1 1.00…e4 1.00…e1
chainis the chain’s number in every method, matchingPosteriorDraw.chainintrajectories.tsv— the join key — and thechain_N/directory the chain’s trace is under (§2, one index base).- Rows are the post-burn-in, thinned set. PGAS writes the set the sim-side recorder already retained and re-applies neither burn-in nor thinning; PMMH rebuilds the file from each
chain_N/trace.tsv, dropping rows withstep < burn_in.
A real PGAS draws.tsv (2 chains, burn_in = 10, thin = 2; beta/gamma estimated, rho/k/I0 fixed):
chain draw beta gamma rho k I0
0 10 2.29325244651855442e-1 2.12145731754385697e-1 5.99999999999999978e-1 2.00000000000000000e1 5.00000000000000000e0
0 12 2.29325244651855442e-1 2.12145731754385697e-1 5.99999999999999978e-1 2.00000000000000000e1 5.00000000000000000e0
Per-method differences that matter to a consumer:
| method | draw column |
value precision |
|---|---|---|
| PGAS | the draw’s true 0-based sweep index, so it joins trajectories.tsv on (chain, sweep) |
all columns {:.17e} |
| PMMH | a within-chain row index (no latent path is saved, so nothing to join to) | estimated columns copied verbatim from the trace (shortest round-trippable Display); fixed columns {:.17e} |
Because PGAS and PMMH carry fixed parameters as constant columns, their file is a complete parameter specification: a posterior-predictive run needs no companion --params.
10.4 Sampler traces (chain_N/trace.tsv, chain_N/parameter_traces.tsv)
One file per chain, streamed during sampling (header flushed immediately, rows batched, so a live tail works). N is the chain’s number (§2, one index base).
PGAS, PMMH and MH write chain_N/trace.tsv via the shared fit::trace_writer::TraceWriter:
{index_col} {loglik_col} log_posterior {extra…} {param…}
The likelihood column is named by the method because its meaning differs: PMMH writes the marginal particle-filter estimate (log_likelihood), PGAS the complete-data conditional value (log_complete_data_ll), which is many orders of magnitude more negative. A shared bare name would invite comparing the two. extra columns are method-specific; param columns follow in [estimate] order. A real PGAS-with-NUTS trace header, from a two-stream fit estimating beta and gamma:
sweep log_complete_data_ll log_posterior trajectory_renewal renewal_b0 renewal_b1 renewal_b2 renewal_b3 renewal_b4 renewal_b5 renewal_b6 renewal_b7 renewal_b8 renewal_b9 collapsed_windows min_alive min_ess min_ess_t as_opportunity as_accept as_proposed transition_ll obs_ll initial_state_ll obs_ll_cases obs_ll_confirmations tree_depth n_leapfrog step_size accept_stat n_divergent energy beta gamma
Three of PGAS’s diagnostic blocks need reading before the positions make sense, and one of them varies in width with the model — so a reader must take the header as authoritative rather than assume fixed column positions:
renewal_b0 … renewal_b9— always ten columns:trajectory_renewalresolved in time, one bin per tenth of the substep series. A bin holding no substep rendersNA, not0.0: “no substep fell here” and “no substep here was renewed” are different diagnoses.collapsed_windows,min_alive,min_ess,min_ess_t— the sweep’s filter health, read together with renewal: renewal says whether the path moved, these say whether the selection had anything to choose from.collapsed_windowsis how many observation windows left every particle at zero density andmin_alivethe fewest particles any window left with a finite weight (gh#783). A finite weight can be negligible, somin_essis the smallest effective sample size(Σw)²/Σw²the weights had at any observation of the sweep andmin_ess_tthe observation time it fell at (gh#685) — the number the resample after that observation actually drew from.NAwhen the sweep scored no observation. A sweep withmin_alivein the thousands andmin_essof 3 is the casemin_alivecannot see: every weight finite, one handful of them carrying all the mass.obs_ll_<stream>— the observation termobs_llresolved by declared observation stream, one column per stream, each summing over that stream’s own observation times and (for an indexed stream) its strata. A row is a sweep and a sweep evaluates the whole likelihood, so every column carries a number on every row even when the streams are on different cadences. They add up toobs_llto floating-point round-off — the scalar sums time-major and the decomposition stream-major.
The index column is sweep for PGAS and step for PMMH. Diagnostic columns render {:.4}; parameter columns use the shortest round-trippable Display (a fixed {:.6} previously zeroed any parameter below ~5e-7, faking a frozen chain and corrupting R̂/ESS).
IF2 and the NLopt optimizers write parameter_traces.tsv instead, e.g.
iteration loglik if2_perturbed_loglik beta gamma
A resumed leaf’s trace.tsv starts as a copy of its base’s and the new segment is appended under the base’s header (§9.5, “A resumed leaf”).
10.5 Observation draws
simulate --obs/--obs-dir/--obs-only/--obs-only-dir draws synthetic observations from the model’s observations {} block. They live in the leaf under obs/{obs_hash8}-{obs_seed}/<stream>.tsv, one file per stream, and are mirrored to the requested path or directory.
Per-stream file:
time weekly_cases
0 0
7 10
The time column is named time here, not t. Values render as an integer when the draw is integral and {:.6} otherwise. The leaf’s stream files never carry a date column — --dates reaches only the mirrors.
The single-file wide form (--obs PATH) prefixes the same optional replicate/scenario/draw key columns as §10.2, then time, an optional date under --dates, then one column per stream. It requires every stream to share a schedule; a multi-cadence model is a hard error directing the user to --obs-dir.
The obs/ child is keyed on (trajectory run_id, obs model hash, obs seed), so observation draws can be iterated without recomputing the trajectory.
--emit-every (gh#656). A model’s emit_schedule is the SIMULATE-only emission cadence — the fit path reads the data file’s own time column and never consults it — so one model can serve a daily and a weekly emission without editing its source. --emit-every N sets every stream, --emit-every NAME=N one stream by its observation-block label (the IR source, so one flag covers a stratified family); the two forms are mutually exclusive, N is a plain number in the model’s time_unit, and a stream whose schedule is a fixed at [...] list is refused by name rather than silently converted to a cadence.
On an incidence stream declared with a uniform covers form, the override also re-widens each row’s window to the new cadence, keeping the form’s anchor — a daily closing_at(time, 1 'days) stream emitted every 7 writes weekly totals closing at their labels, not one day in seven (gh#833). The file it writes is then the file of a model declaring that cadence, not of this one; simulate --obs allows that (nothing has to read the file back), while [synthetic] generation, whose dataset this same model fits, refuses an override that contradicts the declaration and names the closing_at that would emit it. A stream declared with window_start/window_stop columns carries its own boundaries and takes any cadence.
The override is applied at the CONSUMPTION sites — the --obs* writers, the obs/ child, an obs-sourced quantity, and [synthetic] generation — not by rematerializing the compiled IR the way --output-every does. That is deliberate: emit_schedule never enters a likelihood, so moving the model hash for it would re-key a fit against real data over a change that fit cannot see, orphaning a completed fit. Each path therefore keys what the override actually determines:
| path | what re-keys |
|---|---|
simulate --obs* / obs/ |
the obs_hash naming the child, so two cadences are two subtrees under one shared trajectory leaf |
fit run with [synthetic] |
the generated data’s own bytes, which FitDigest.data already hashes — correct, the data changed |
fit run on real data |
nothing; the flag is refused there, since it could only do nothing |
Two cadences share one trajectory leaf because the trajectory bytes do not depend on the emission cadence. The obs/ child is a declared boundary in the leaf’s exact-set (keyed by the directory NAME), so a second subtree beside the first is not an orphan.
10.6 Fit sidecars
Fit-wide, at fits/{fit_stem}-{h8}/ (outside any leaf, so not exact-set checked):
| file | content |
|---|---|
fit.meta.json |
source model + model_identity, source config (both relative to the fit folder) + hash, per-stream data hashes, estimated/fixed sets, resolved priors, parameter provenance, and the observation-stream schema |
fit.toml.original |
verbatim copy of the config as run |
model.camdl.original |
verbatim copy of the model source |
model.ir.json |
the compiled IR the fit ran against |
model.graph.json |
the model flow graph |
model.render.json |
the display/LaTeX rendering payload |
Per method leaf, alongside run.json. The set is method-dependent; every file present is listed in run.json.artifacts and subject to the exact-set gate, except events.jsonl, which the run appends to after the leaf commits (§10.11).
Common to both families:
| file | content |
|---|---|
fit_state.toml |
θ̂ + run state, with method naming the algorithm that wrote it, best_chain as the number of the chain_N/ directory that won, plus chain_init_source and chain_starts_kind; the artifact a from_mle start consumes as a deps edge |
chain_starts.tsv |
one row per start attempt — the point each chain ran from, and every draw a retry rejected with its ess and reason — under a header naming the starts rule, its kind and the rejected count (§6.6) |
progress.json |
the run’s liveness/progress heartbeat, written live, and the terminal state it ends on; every method but pfilter writes it (§10.10) |
events.jsonl |
what the run reported — its checks, chain progress, findings and end-of-run report — one JSON line per event, as it happened; every method writes it; outside the manifest and the run’s identity (§10.11) |
An optimizer leaf (IF2, NLopt) additionally holds:
| file | content |
|---|---|
mle_params.toml |
flat name = value params plus a [provenance] block (camdl version, timestamp, tamper content_hash, fit_hash, backend, dt, model + model_identity, per-stream data hashes, seed, method, chain, log-likelihood, loglik_sd, n_particles, ESS) |
final_params.toml |
the selected parameter vector |
chain_N/final_params.toml |
per-chain endpoint |
chain_N/parameter_traces.tsv |
per-chain trace (§10.4) |
chain_evaluations.tsv |
per-chain loglik evaluation summary |
diagnostics.tsv |
per-parameter convergence diagnostics |
A sampler leaf (PGAS, PMMH, MH) additionally holds:
| file | content |
|---|---|
draws.tsv |
the thinned posterior cloud (§10.3) |
chain_N/trace.tsv |
per-chain trace (§10.4) |
chain_N/resume_state.bin |
bincode resume state, guarded by its own config hash (§9.4) |
chain_N/trajectories.tsv |
PGAS only — the smoothed latent paths, tidy/long, keyed chain draw time t_start t_stop [date date_start date_stop] (§10.1 for the period columns), with a # camdl-trajectories v1 header line |
chain_N/trajectories.json |
the matching manifest (format, version, method, granularity, n_chains, n_draws, columns, model_hash, conditioned, calendar, …) |
latent_convergence.tsv |
PGAS only, ≥ 2 chains — per (substep, trajectory column): status (constant/frozen_disagree/mixed), chain-mean range, R̂ and ESS over the saved paths; binned in pgas_summary.json; written at run end, or by fit summary from chain_N/trajectories.tsv when absent (gh#822) |
filter_ess.tsv |
PGAS only — per (chain, observation): mean and minimum filter ESS over the retained post-burn-in sweeps and the sweep count, with a pooled chain = all block first; the filter_ess block of pgas_summary.json carries the summary (particle count, starvation bar, min / 10% / median of the mean profile, starved observations worst first). Omitted when no sweep scored an observation (gh#685) |
<algorithm>_summary.json |
pgas_summary.json, pmmh_summary.json, mh_summary.json — one file per algorithm, deliberately never shared; each names its algorithm under method and carries schema = "camdl.fit-summary/v2", which every reader requires |
diagnostics.json |
R̂ / ESS / divergence diagnostics, and one bad_init record per refused chain — see below |
A chain that took no step after burn-in (gh#1032) is one whose retained draws are all one parameter vector: every estimated parameter has the same value in every retained draw. Its draws are a point mass at wherever the chain stood when warm-up ended, and they enter every pooled number. The run records such chains in the sampler summary under frozen_chains, a list of chain numbers ([] when every chain moved; the key is absent in a summary written before camdl recorded it), and raises one chain_frozen finding per chain in diagnostics.json, severity error:
| field | what it holds |
|---|---|
chain_id |
the chain’s number |
n_draws |
its retained draws, all equal |
burn_in |
the warm-up as configured: sweeps for pgas, iterations for pmmh and mh |
n_steps |
post-burn-in steps in which θ could have moved |
sampler |
tagged by kernel: {"kernel": "nuts", "step_size", "n_divergent"} — the step size adaptation ended on (null if not recorded) and how many post-burn-in steps diverged — or {"kernel": "random_walk"}, no proposal accepted |
A frozen chain’s acceptance_rate_unhealthy finding is not raised, and under NUTS neither is its divergent_transitions: the chain_frozen finding states the stronger fact and carries the divergence count. Any recorded frozen chain makes the method’s verdict {"kind": "chains_frozen", "chains": [...]} — not converged, whatever the R̂, since one frozen chain among moving ones can leave R̂ finite and small. fit summary --exclude-chains recomputes the frozen chains over the retained ones from draws.tsv by the same test.
The per-chain log-likelihood comparison (gh#406, gh#1008) is computed once, at the end of the run, and recorded in the leaf; fit summary, the warm-start source check and camdl-scope read the record and do not recompute it. Each chain’s mean log-likelihood is compared with the pooled mean of the other chains, Z = |Δ| / se(Δ) against a Bonferroni cutoff at family-wise α = 0.001, with backward elimination of the worst chain; a chain with fewer than 10 effective draws, a non-finite mean or no standard error is not assessed. A sampler compares the retained mean of log_likelihood (pmmh, mh) or obs_ll (pgas) with its Monte Carlo standard error, and records it in its summary under chain_loglik_agreement; IF2 compares each start’s clean evaluation (clean_loglik) with the standard error of its replicate mean, and records it in fit_state.toml under the same key. The record:
| field | what it holds |
|---|---|
column |
what each chain’s estimate is a mean of: log_likelihood, obs_ll or clean_loglik |
column_absent |
present and true only when traces were read and none carries column |
family_alpha, min_ess |
the family-wise α and the fewest effective draws assessed |
chains |
one row per chain, ascending: chain, mean, and outcome, tagged by status: {"status": "assessed", "diff", "se", "z", "cutoff", "flagged"} (diff in nats, the chain minus the others) or {"status": "not_assessed", "reason"} with reason one of non_finite_mean, too_few_effective_draws (with ess), no_standard_error, too_few_chains |
complete_data |
pgas only: complete and transition, each chain’s mean log_complete_data_ll and transition_ll, parallel to chains; shown, never compared |
The comparison is over every chain the run kept; fit summary --exclude-chains does not recompute it and says so. A sampler’s record reaches fit summary --format json, and so events.jsonl’s method_report, as method_result.diagnostics.chain_logliks (null when the leaf does not record one). Each chain a sampler’s comparison flags is also a chain_loglik_differs finding, severity warning, carrying chain_id, column, diff, z and cutoff. IF2’s flagged starts are not findings: the standard error of a clean evaluation is small enough that starts a tenth of a nat apart differ beyond it, and whether a spread matters is the decibans leg of the source gate, which has a floor in log-likelihood units; the comparison names the chains in that refusal. A leaf written before the comparison was recorded shows it as not recorded.
The mle_params.toml content_hash is a tamper hash — SHA-256 over {name}={value:.12}\0 pairs, truncated to 8 hex — so a hand-edited parameter value is detectable; camdl simulate --params checks it and refuses an edited file (§2.2). It is not a content hash of the file.
10.7 A refused chain’s bad_init record
When a chain cannot start — under a spread rule, when none of its MAX_START_ATTEMPTS = 10 draws could be scored (§6.6); under a point rule, on its one try — the run skips it and continues (ran K of N chains on stderr, n_good_chains in fit_state.toml), every attempt is a row of chain_starts.tsv, and diagnostics.json carries a bad_init record for it: the chain id (the number the stderr line, chain_starts.tsv and progress.json all use), the full estimated-parameter vector it was refused at (so the refusal is reproducible), a one-line prose reason that opens with how many starts were tried, and an attempts array.
attempts is the structured half, and it is what downstream tooling should read — the prose is rendered from the same values, so parsing the sentence gets you the same facts less reliably. One entry per observation stream scheduled at the failing observation:
| field | what it holds |
|---|---|
stream |
the declared stream name, not its position in the observation queue |
time, date |
the observation’s model time, and its calendar date when the run is anchored |
cell |
Scored { y_obs }, Hole (scheduled, value missing), or NotScheduled (a sibling stream’s cadence). All three contribute 0.0 to the joint, and they are different facts |
projected_max, projected_median |
the stream’s projected quantity across the live particles. null when no particle was live, or every projection was NaN |
n_projected_zero, n_projected_nan |
how many projections were exactly zero, and how many were NaN |
n_neg_inf, neg_inf_causes |
live particles whose per-stream log-density was -inf, with the specific guard that fired |
n_live, n_dead, n_particles |
n_live + n_dead == n_particles |
Dead particles are excluded from every other field, and this is the point of the split rather than bookkeeping. A particle killed earlier by a chain-binomial overshoot carries -inf without the observation model having been consulted, so a mixture of process-model deaths and observation refusals would otherwise read as a unanimous observation refusal. A refusal with n_dead near n_particles is a process-model finding, not an observation one.
Four readings, which have different fixes:
| the numbers | what it means |
|---|---|
projected_max exactly 0, y_obs positive |
the model cannot produce this observation here — structural |
projected_max small against a large y_obs |
it can; this draw is far out — the prior is the thing to change |
projected_max finite, n_neg_inf == n_live |
read neg_inf_causes; this is a support violation, not a location |
n_dead near n_particles |
a process-model failure that never reached the observation model |
Every number in diagnostics.json, in a bad_init record or any other, is a JSON number when finite and the string "inf", "-inf" or "NaN" when not — an R̂ is "inf" when every chain sat at its own single value. null means a field has no value (projected_max with no live particle, param on a chain-level acceptance finding), never a non-finite one. A file written before this encoding carries null for a non-finite number, which cannot say which of the three it was; fit summary refuses that entry by name and still reads the rest, its bad_init records included.
10.8 Generated quantities
simulate --quantities-out DIR writes DIR/quantities/<name>.tsv plus a DIR/quantities.json manifest (schema: "camdl.quantities/v1"). A single fixed-parameter run emits a bare value per leaf (point mode); a --draws/--replicates/--seeds run emits banded quantiles.
Quantities are a regenerated sidecar: they never enter a CAS leaf and never enter run identity (quantities and contrasts are the model fields excluded from Model::hash_into, §9.2.3). This is sound only because camdl simulate recomputes every cell even on a cache hit (§9.3) — the quantities are always evaluated from the run just performed, never read back from a stored leaf.
Without the flag, a model that declares quantities prints a note and skips them rather than erroring.
10.9 Declared column roles (run.json.output_schema)
Rather than reconstruct a writer’s column order — which would drift from the writer — camdl reads each written file’s actual header and classifies every column, so the declaration cannot disagree with the file it describes (cli/src/output_schema.rs). The entry is keyed by leaf-relative path, with {n} standing in for the chain index.
role (the table’s default view) is one of trajectory, observation, posterior_cloud, trace, predictive, landscape.
Each column’s role is one of time, window_start, window_stop, iteration, chain, replicate, scenario, dimension, state, flow, incidence, param_estimated, param_fixed, observable, quantile, diagnostic. Classification is by reserved name (chain, replicate, scenario; t/time/date → time; t_start/date_start → window_start; t_stop/date_stop → window_stop; sweep/step/draw/iteration/point_id → iteration), then prefix (flow_, inc_), then membership in the estimated / all-parameter sets, then a per-table default (state for trajectories, diagnostic for fit tables).
time and iteration are deliberately distinct: a trajectory’s x-axis is physical time and a trace’s is a sampler index, and conflating them is a real rendering bug. window_start/window_stop are not an axis either: they are the boundaries of the half-open period a row’s flow columns cover (§10.1), so a consumer draws a flow as a bar over that span and a state as a point at time.
The rendering rule is then mechanical: the x-axis is the time or iteration column; group by chain / replicate / scenario; facet by dimension; series are state / param_estimated / observable, with flow and incidence spanning window_start–window_stop; ribbons are quantile; overlays are diagnostic. Because the index column is spelled differently by method (sweep, step, draw, iteration), a consumer reads the role and never the name.
Two producers emit a schema today. A sim leaf declares traj.tsv (table role trajectory, unrecognized columns defaulting to state). A completed fit leaf declares draws.tsv (role posterior_cloud) plus one entry per per-chain trace filename — trace.tsv for PGAS/PMMH/MH, parameter_traces.tsv for IF2/nlopt — read from the first chain directory with a readable file, so an early crashed chain does not drop the entry. Both are best-effort: an unreadable file is omitted rather than failing the run.
pfilter, survey, profile, sim_ensemble, and the fit predict outputs declare no schema. Of the vocabulary above, the column roles dimension, observable, and quantile and the table roles observation, predictive, and landscape are defined but unreachable — no producer emits them.
The schema is recorded, never hashed, so attaching it cannot re-key a run. It is omitted from run.json when empty.
There is no
camdl summarizeand no automatic per-run summary table (peak_X/tpeak_X/final_X/integral_X). Thecamdl experiment summarizesubcommand that produced those was removed. Equivalent reductions are expressed in the model’squantities {}block (§10.8).
10.10 progress.json: what a method run says while it runs, and how it ends
A background thread rewrites progress.json every five seconds — a fixed wall-clock timer, not a step boundary, because one national-scale PGAS sweep can take minutes — so it is the one thing a watcher can read while a method runs. Alongside updated_at, pid and the state counter it carries a chains block for any method that reports per-chain liveness:
{
"updated_at": 1789038341,
"pid": 2882,
"state": { "running": { "phase": "burn_in", "step": 276, "total": 2000 } },
"chains": {
"total": 24,
"running": 1,
"completed": 0,
"refused": 23,
"not_started": 0,
"chains": [
{ "chain": 0, "status": "refused", "reason": "non_finite_start" },
{ "chain": 7, "status": "running" }
],
"numbering": "counted from 0, matching the chain_N/ directories and draws.tsv's chain column"
}
}A chain is refused at its start (§10.7), so how many of the chains a run paid for are sampling is knowable in its first minutes; fit_state.toml’s n_good_chains and the bad_init records report the same thing only once the run has finished. running + completed is the number that got past their start — total - refused is not the same thing mid-run, since a chain that has not started is neither good nor bad.
A chain with no row has not started. That is the distinction an empty chain_N/ directory cannot make: a refused chain and one queued behind --parallel call for opposite responses (respecify, or wait), so a chain that has not been picked up by a worker is counted in not_started and reports nothing. The block is absent, not null, for a method that does not report per-chain liveness — today every method but PGAS — and chain here is the same number chain_starts.tsv’s chain_id column, the bad_init records and the chain_N/ directories use, so joining any two of them takes no arithmetic.
Which methods write it, and what a step is
Every method writes progress.json except pfilter, whose unit of work is a replicate of one fixed point — neither an MCMC phase nor a search, so it publishes no counter rather than one labelled with a phase it is not in. For the rest, step of total counts:
| method | one step is | total |
phase |
|---|---|---|---|
pgas |
one sweep | sweeps |
burn_in → sampling at burn_in |
pmmh, mh |
one MCMC iteration | iterations |
burn_in → sampling at burn_in |
if2 |
one cooling iteration | iterations |
optimizing |
nl-sbplx, nl-bobyqa, nl-lbfgs |
one objective evaluation | max_evals |
optimizing |
step is the furthest any chain has reached, and it never moves backward.
The terminal state
A method run that finishes writes "state": "done" after every other output in the leaf is on disk, so done is the cue that the final statistics are ready to read. A run that camdl stops writes "state": {"failed": {"reason": …}}, carrying the same sentence the command printed on stderr — so a reader holding only progress.json learns what a reader of the terminal learns. Both are written before the leaf is finalized.
A running record that has stopped being refreshed means neither: it is what a run leaves when it is SIGKILLed or panics, and deadness is the reader’s inference from staleness, never a self-claim — a killed process cannot write “I died”. Treat a running record older than a few refresh intervals as presumed dead, and a failed record as a failure camdl saw and explained.
10.11 events.jsonl: what a method run reported, as values
The terminal shows a method run’s checks, progress, findings and end-of-run block as text. events.jsonl records the same values, one JSON object per line in the order the run reported them, so a reader that is not the terminal — camdl ’scope, a terminal UI — reads values and never parses the text. Every method writes it, pfilter included, from the moment the method begins.
Every line carries four fields beside the event’s own:
| field | what it holds |
|---|---|
schema |
"camdl.events/v1". A changed meaning or a removed field is a new version; a new event or field is not |
seq |
the line’s place in the file, from 0 |
elapsed |
seconds since the method began |
event |
which event: begin, check, progress, finding or method_report |
The events:
event |
when | its fields |
|---|---|---|
begin |
first, as the method starts | method; header, the run’s opening block (config, method, model, estimate, fixed, starts, output), which no other file in the leaf holds until the leaf commits; acceptance, the healthy band the method’s acceptance diagnostics apply (kernel, band_lo, band_hi), or null for a method without them |
check |
as each check before sampling is shown | check, tagged by kind: obs_stream (name, carrier, family), filter_noise (check: n_particles, pairs, sigma, s, ll_mean), method_caveat (method, note, series_len), starts (mode, params, note) |
progress |
per chain, at its first step, then at most every 30 seconds, and at its last step | chain; step of of, the chain’s progress bar’s count, which is the chain’s own step (a chain resumed from step 2000 of 2500 reports 2001 first); loglik (value, and kind: marginal, if2, ode_marginal or complete_data — PGAS’s, not comparable with the others) or null; accept or null |
finding |
as each finding is pushed | finding, the record diagnostics.json holds for it (kind, tagged by type; severity; message; method; timestamp) |
method_report |
last, as the end-of-run block is shown | outcome: completed, with report, the method’s report: the object fit summary --format json gives for it under methods, built from the run’s memory rather than its files, with non-finite numbers spelled as that document spells them ("NaN", "inf", "-inf"); or failed, with error, the sentence the command printed |
progress is written whatever --progress says; the display mode governs only the terminal. A step is what progress.json’s step counts for the method (§10.10). PGAS, PMMH, mh and IF2 chains report progress; NLopt and pfilter report none.
The acceptance band. acceptance on begin is {"kernel": "random_walk", "band_lo": 0.15, "band_hi": 0.5} for pmmh and mh, whose θ-move is random-walk Metropolis–Hastings; for pgas it is {"kernel": "nuts", "band_lo": 0.6, "band_hi": 0.95} when the sampler runs NUTS on the model (it needs the compiler’s rate gradients) and the random-walk band when it falls back to Metropolis-within-Gibbs; it is null for if2, the NLopt methods and pfilter. The bounds are fractions, inclusive at both ends. An acceptance_rate_unhealthy finding carries the same kernel, band_lo and band_hi beside its rate, so a reader has the band whether or not a finding fires and never parses it from the message, which is rendered from those fields. An entry written before the band was recorded has neither field: it is read, its band is not recorded (no number is assumed for it), and fit summary prints the message it stored, as written.
A finding’s line is written when the run pushes the finding: a refused chain’s bad_init as the chain is refused, and a sampler’s per-chain and R̂ findings after its chains join, in chain order, after the last chain’s progress. Numbers follow §10.7’s rule: a non-finite number is the string "inf", "-inf" or "NaN", and null means no value.
How a log ends. A run camdl finished or stopped ends its log with method_report, whichever method failed and wherever, its findings recorded in diagnostics.json beside it. A log whose last line is something else was cut off: the process was killed or panicked, and progress.json says which (§10.10).
Writing. Each line is written whole, in one write, so a reader following the file sees whole lines; the file is not synced to disk line by line. A line that cannot be written fails the run, as an unwritten diagnostics.json does (exit status 1, error: cannot write …/events.jsonl: …): before the method is recorded done if the failure came while it ran, after the end-of-run block if it came at the report, the leaf then already committed.
Outside the manifest and the identity. The end-of-run report is built from the committed leaf, so it is appended after the commit; the store’s exact-set check and manifest pass over events.jsonl by name (runid::EVENT_LOG), and it is never listed in run.json.artifacts. It enters no hash: it is a record of what one execution showed, not an input to anything a run computes. In consequence:
- A cache hit writes nothing. The method does not run, so the stored leaf keeps the log of the run that computed it, and the cache-hit line on stderr is not an event.
- A resumed run writes its own log in its new leaf; the base leaf’s log is not copied or touched.
- A recomputed leaf (
--force, or a staleRunningleaf reclaimed after a killed run) starts a new log: the reclaim clears the dead run’s log with its other files, and the log is created empty when the method begins.
11. Predictive Workflows
Every workflow in this section is a camdl simulate run over a cloud of parameter vectors (--draws) rather than a single point. The four sources are prior, posterior, uniform, and a path to a parameter TSV. A run with --draws, --replicates, or --seeds is an ensemble: one content-addressed store leaf per cell, plus an ensemble node that names them.
11.1 Prior Predictive Check
“Does my model, under priors, generate data that looks plausible?”
Two forms. With no fit config, priors come from the model’s ~ declarations:
camdl simulate models/sir.camdl --draws prior -n 500 \
--replicates 5 --obs-dir prior_pred/With --fit, the fit config’s [estimate.<p>.prior] blocks take precedence and the model’s ~ priors are the fallback (§12):
camdl simulate models/sir.camdl --draws prior --fit fits/02_fix_beta.toml \
-n 500 --replicates 5 --obs-dir prior_pred/-n is required for --draws prior and --draws uniform; omitting it is a hard error (error: --draws prior requires -n N).
Model-only form: every parameter needs a prior or a value in every scenario. The draws carry only the parameters with a prior; every other parameter resolves per cell from --params, that cell’s scenario, or --param. --scenario a,b is two sets of cells, as on every other simulate path, and each resolves against its own scenario — a value one scenario sets never reaches the other. A parameter left unset in some scenario is refused before any cell runs, naming the scenario:
$ camdl simulate v2.camdl --draws prior -n 3 --scenario half,other
error: parameter 'c' has no prior, and nothing gives it a value for scenario 'other'.
--draws prior samples only the parameters that have a prior; every other parameter takes its value from --params FILE, the scenario's `set`, or --param NAME=VALUE.
Fix options: declare a prior on 'c' in the model's parameters {} block (`c : <type> in [lo, hi] ~ <dist>(...)`), supply `--fit FIT.toml`, set it with --params FILE or --param c=VALUE, or set it in scenario 'other'.
--fit form: every estimated parameter needs a proper prior. Flat is refused, because there is no finite distribution to sample:
$ camdl simulate seir_observations.camdl --draws prior --fit if2_a.toml -n 3
error: --draws prior requires a proper (non-flat) prior on every estimated parameter.
Missing or flat priors: beta
To fix, either:
(i) add `prior = { <dist> = { ... } }` to `[estimate.beta]` in your fit.toml (e.g. `prior = { log_normal = { mu = 0, sigma = 1 } }`), OR
(ii) add a `~ <dist>(...)` declaration to parameter `beta` in your .camdl model file.
An explicit prior = { flat = {} } is rejected here too, with an added note explaining why (improper uniform, infinite support).
Recording the draws. --draws-out <PATH> writes the sampled vectors as a TSV with one row per draw and one column per sampled parameter. Parameters the measure does not vary have no column, so the file depends only on the measure and the seed — not on --params or the scenario order — and it round-trips through --draws <PATH> given the same --params and scenarios:
$ camdl simulate v2.camdl --draws prior -n 3 --params b0.toml --draws-out p.tsv
$ head -2 p.tsv
beta gamma
2.90465033202471634e-1 1.63870215243367284e-1
Prior draws are rejection-sampled against each parameter’s declared bounds; the run reports per-parameter rejection counts when truncation was active.
11.2 Posterior Predictive Check
“Does my fitted model generate data that looks like the real data?”
Prefer --draws posterior --fit <fit results dir>, which resolves the fit’s canonical post-warm-up cloud for you rather than making you find the file:
camdl simulate models/sir.camdl \
--draws posterior --fit results/fits/fit_pgas-4dadedae \
--replicates 10 --obs-dir ppc/It prints what it resolved:
draws: posterior — 2 draws from pgas method 'pgas' (…/pgas-a0b1da4f/seed_1-e6a47b86/draws.tsv)
Resolution is by artifact, not by method name: a leaf has a posterior iff it wrote draws.tsv. An optimizer-only fit (IF2, nl-sbplx, nl-bobyqa, nl-lbfgs) resolves to an error rather than dressing a single point up as a distribution. The canonical file is the method leaf’s draws.tsv — post-warm-up and thinned — not trace.tsv, which carries warm-up rows for live observability. There is no <fit-dir>/posterior/ directory; the path is <fit-dir>/<method>-<h8>/seed_<N>-<h8>/draws.tsv.
A raw TSV path still works. If the file carries only the estimated columns (a posterior trace tail), pass --fit alongside it and the fit’s [fixed] block backfills the missing parameters — never overwriting a column the file provides:
camdl simulate models/sir.camdl --draws draws.tsv --fit fit.toml \
--replicates 10 --obs ppc.tsvThe purpose-built alternative. camdl fit predict writes the predicted-vs-observed artifact directly from a completed fit, banded over the posterior, with no re-plumbing of draws:
camdl fit predict results/fits/fit_pgas-4dadedaeIt emits, under the run directory:
predictive/<stream>.tsv time | <dims…> | horizon | treatment | fit_rhat_max | fit_ess_min
| rhat_mean | ess_mean | rhat_pred | ess_pred
| n_draws | q05..q95
observed/<stream>.tsv time | <dims…> | value
with two horizons where the backend supports them — free_forward (one forward replay per draw) and one_step (the filter’s one-step-ahead predictive; chain- binomial only, a hard error on an ODE fit). The posterior cloud is subsampled to --n-draws (default 200) by a strided pick across the whole cloud.
11.3 Scenario Prediction Under Posterior Uncertainty
“What would happen under an SIA, given what we learned from the data?”
camdl simulate models/sir.camdl \
--draws posterior --fit results/fits/<run>/ \
--scenario baseline,with_sia \
--replicates 10 --obs-dir obs/--scenario a,b is a partition of the cell grid: each scenario is its own set of runs, not a layering of one scenario on another. For each (draw, seed) pair both scenarios are simulated with the same seed, giving paired comparisons that propagate posterior uncertainty.
Every arm starts from the model that was fitted. A fit config’s top-level scenario or enable/disable (§6.2) is part of the problem the posterior describes: fit run estimated under it. Every command that replays a fit applies it — simulate --draws posterior --fit, fit predict (the fitted arm, every --scenario arm, the one-step filter), the contrast arms, pfilter --fit and the prequential compare derives. For a stored fit the selection is read from its archived fit.toml.original; the fit’s run_id already hashes it, as part of the config’s problem half.
On the replaying commands each arm’s own changes apply on top of the fit’s selection, the way a scenario composes a sub-scenario: the fit’s enable/disable lists first, the arm’s after, and an explicit disable wins. So on a fit with enable = ["lockdown"], fitted runs lockdown, an arm with_sia { enable = [sia] } runs lockdown and sia, and an arm no_lockdown { disable = [lockdown] } runs neither; an arm that toggles nothing is the fitted model. The command says so on stderr when the fit selected anything:
fit predict: the fitted model runs the fit's own enable ["lockdown"] / disable []; every scenario arm applies its changes on top of it
Only the fit’s intervention lists form the base. A fit scenario’s set/scale is already in the draws: fit run refuses a scenario that moves a [fixed] value or touches an [estimate] parameter (§1.3), so applying it again could only re-apply a value the draws carry. On simulate the base enters each cell’s scenario identity level (its lists lead the arm’s), so a cell replayed under a fit’s selection never shares a run_id with the same draw and seed replayed without it.
The coupling is paired-seed CRN, not event-keyed RNG. The runtime uses a stateful ChaCha8 PRNG, so two arms agree only while they consume draws in the same order. enable/disable scenarios are byte-identical up to the first state divergence; set/scale scenarios that perturb propensities from t=0 are correlated but never identical. Any structural change that reorders draws also breaks the coupling.
Do not hand-difference the two arms for a headline number. The model-level contrasts { } block does this correctly and is auto-emitted by camdl fit predict:
quantities {
total = final(D) # total deaths over the run
}
contrasts {
averted = no_sia.quantities.total - with_sia.quantities.total
}The fork point is derived, not declared: the reducer diffs the two arms’ live intervention sets, finds the toggled intervention, and forks both arms at the last saved trajectory snapshot strictly before the instant it fires — on the arms’ chain-binomial grid t_start + k·dt, not its declared time (declared at 2.4 with dt = 1, it fires at 2) — so “fork at or after the intervention” is unrepresentable. The resumed arms do not re-apply an effect at the fork time: the saved state is already post-effect. Both arms share the smoothed latent state at the fork and the per-draw seed, then desync by design after it. Results are banded over the forkable draws into contrasts/<name>.tsv. A contrast with no toggled intervention (a parameter-only scenario) or one toggled by a parametric or reactive fire time is skipped with a note, never silently.
A chain selection reaches the contrast. fit predict --exclude-chains narrows the forkable set to the retained chains before the fork, so a contrast bands over exactly the cloud predictive.json‘s chain_selection block describes — the contrast’s n_used and the free-forward rows’ n_draws are one number. The retained-chain scope is printed alongside the count. Excluding chains narrows an already-partial set (only path-saved draws are forkable); if the intersection is empty the run is refused by name, never emitted as an empty band.
--quantities-out bands each scenario separately. quantities { } bands are taken over draws × replicates × seeds; scenario is a partition of that grid, not part of the band. A multi-scenario run therefore writes one band per scenario, never a pooled one: each quantities/<name>.tsv carries a leading scenario column (scenario n_draws q05 q25 q50 q75 q95, one row per scenario) and quantities.json lists one entry per quantity and scenario.
11.4 Uniform Exploration
“What does the model do across parameter space?”
camdl simulate models/sir.camdl --draws uniform -n 500 --replicates 1Samples uniformly from each parameter’s declared bounds. No Bayesian pretension — this is space-filling exploration for model debugging. For a likelihood-aware version of the same question (is the model identifiable from this data at all?) use camdl survey, which runs a Latin-hypercube over [estimate] bounds and scores each point.
11.5 Replicate Fits at Known Truth
The orchestration that generates synthetic datasets and fits each one exists — it is the [synthetic] block in a fit config, not a separate subcommand:
[synthetic]
true_params = "truth.toml" # ground truth used to generate the data
sim_seeds = "1:20" # one dataset per seed
backend = "chain_binomial" # generation backend (fits declare their own)
scenario = "baseline" # optional: scenario for GENERATION onlycamdl fit run then generates one dataset per sim seed into <fit_dir>/synthetic/data/ds_NN/ — one file per observation stream, under the columns that stream declared, exactly as simulate --obs-dir writes them — and runs the [method] once per dataset. Each dataset is bound and read back through the loader real data uses, so a windowed stream (one declaring window_start/window_stop columns) generates and fits like any other. Each grid cell is its own content-addressed fit, readable with camdl list / show / cat.
Two honest limitations:
- This is calibration at a fixed truth, not simulation-based calibration. Classical SBC (Talts et al. 2018) draws θ from the prior per dataset and checks rank uniformity;
[synthetic]holdstrue_paramsfixed across every dataset and asks whether the estimator recovers that one point. - The cross-cell roll-up — the parameter-recovery coverage and bias table — is not built. The run prints
note: grid summary / coverage are derived views — owed by `fit recovery` (gh#150 / gh#154). Until then the per-cell fits are there and the aggregation is the reader’s to do (camdl fit table <root> --format csvis the starting point).
12. Priors: Beliefs Belong With Parameters
Priors are declared in the model file with ~ syntax on the parameter:
parameters {
beta : rate in [0.01, 2.0] ~ log_normal(mu = -1.0, sigma = 0.5)
gamma : rate in [0.05, 1.0] ~ half_normal(sigma = 0.3)
rho : probability in [0.001, 1.0] ~ beta(alpha = 2.0, beta = 5.0)
}Why in the model file? Priors are beliefs about parameters — they answer “what do I think about beta before seeing data?” That belief belongs with the parameter declaration, where anyone reading the model can see it. This design follows Stan, PyMC, and Turing.jl.
Camdl exists to support decisions about people’s lives. Getting uncertainty wrong means making confident-looking recommendations on shaky foundations. Prior predictive checks are the first line of defence: “do my stated beliefs produce data that looks plausible before I’ve seen any real data?” Making priors discoverable and declarative in the model file is part of doing uncertainty right.
12.1 Precedence
Two chains, resolved by the same code (resolve_prior in fit/runner.rs) but with a different tier-3. The difference is deliberate and load-bearing.
camdl fit run, sampler methods (pgas, pmmh, mh):
1. fit.toml [estimate.<p>.prior] = { <dist> = { ... } } → source "fit.toml"
2. fit.toml [estimate.<p>.prior] = { uniform = {} } → uniform over bounds
3. model IR parameter.prior (from `~ <dist>(...)`) → source "model"
4. fit.toml [estimate.<p>.prior] = { flat = {} } → source "flat (explicit)"
── otherwise ──
HARD ERROR before the fit starts.
There is no implicit flat fallback on the fit path. A fit run chain is treated as the canonical posterior downstream (fit_summary.json, fit predict, compare), so silently targeting the unconditioned likelihood is not allowed. The refusal names every parameter and all three remedies:
$ camdl fit run fit_noprior.toml --seed 1
error: [method] (algorithm = "pgas") has parameters with no resolved prior:
beta no prior in fit toml, no `~` in model file
To proceed, do one of:
(i) Declare `prior = { <dist> = { ... } }` in the fit toml's
[estimate.<param>] for each listed parameter.
(ii) Declare a `~ <dist>(...)` prior in the model file for
each listed parameter.
(iii) Opt into flat priors explicitly via
`prior = { flat = {} }` in the fit toml — only do this if you
intentionally want the chain to target the unconditioned
likelihood (scaled-likelihood posterior).
camdl profile resolves the same first three tiers, then falls back to Prior::Flat (source flat_fallback) without a warning: every profile cell is a maximization (if2 or an nl-* optimizer) that reads no prior, so the resolution is provenance only. It is written to the profile-base sidecar and keys the profile’s base identity. --starts from_prior draws starting points from the resolved priors.
Per-parameter prior provenance is written to fit.meta.json under resolved_priors and under partition.parameters.<p>.prior, for every method (sources fit_toml, model_ir, flat_explicit, and flat_fallback on a problem no sampler can run on). A profile writes resolved_priors to its profile-base sidecar.
12.2 When priors are used at all
| Method algorithm | Prior used? |
|---|---|
pgas, pmmh, mh |
Yes — the prior density enters the acceptance ratio; a missing prior is a hard error |
if2 |
No — iterated filtering is maximum likelihood; the code never reads a prior |
nl-sbplx, nl-bobyqa, nl-lbfgs |
No — deterministic MLE |
pfilter |
No — likelihood evaluation only |
--draws prior needs a proper prior on every sampled parameter; every other parameter is left out of the draws and resolves per cell from the fit’s [fixed], --params, the scenario, or --param (§11.1).
12.3 Supported distributions
Nine families, all accepted in the DSL ~ position with keyword arguments only:
| Distribution | DSL syntax |
|---|---|
uniform |
~ uniform(lower = L, upper = U) |
normal |
~ normal(mu = M, sigma = S) |
log_normal |
~ log_normal(mu = M, sigma = S) |
half_normal |
~ half_normal(sigma = S) |
beta |
~ beta(alpha = A, beta = B) |
gamma |
~ gamma(shape = K, rate = R) |
exponential |
~ exponential(rate = R) |
log_uniform |
~ log_uniform(lower = L, upper = U) |
truncated_normal |
~ truncated_normal(mean = M, sd = S) |
A hierarchical (partially pooled) prior appends a dimension: ~ log_normal(mu = mu_h, sigma = sigma_h) | age, whose arguments are expressions over hyperparameters re-evaluated at each MCMC step.
The DSL and the fit-toml spellings of normal differ, and the compiler will tell you. In the DSL it is mu/sigma; in fit-toml it is mean/sd:
$ camdlc check m.camdl # with `~ normal(mean = 0.3, sd = 0.1)`
error[E231]: parameter 'beta': prior 'normal' missing required argument 'mu'
# fit.toml
[estimate.beta]
prior = { normal = { mean = 0.3, sd = 0.1 } }Two fit-toml-only forms have no DSL spelling:
prior = { flat = {} }— the explicit improper-uniform opt-in (§12.1).prior = { uniform = {} }— uniform over the parameter’s bounds, taken from[estimate.<p>].boundsif present, else the model’sin [lo, hi]. Declaring neither is an error naming both places you could fix it.
Parameterization conventions (log-scale log_normal, rate-parameterized gamma, truncated_normal truncated at the declared in [lo, hi]) are in the language spec’s priors section, which is the normative source for the surface.
12.4 Prior support and the search box
There is no transform key in [estimate.<p>] (a file carrying one is refused at load, naming the line to delete). The transform follows from the parameter’s resolved bounds and declared type (§6.3), and every prior density reads the logarithms it needs from the transformed coordinate, so the prior is exact under whichever transform results. What is checked, before the fit starts, is that the prior puts mass over the whole search box: a prior that is zero on part of the box would be a wall by another route.
| Prior family | The resolved search box must lie within |
|---|---|
log_normal, half_normal, gamma, exponential |
[0, ∞) |
beta |
[0, 1] |
uniform(L, U), log_uniform(L, U) |
[L, U] |
normal, flat |
any box |
truncated_normal |
equal to its truncation |
The error names the parameter, the prior’s support, the search box that reaches outside it, the two fixes (narrow bounds in [estimate.<p>], or choose a prior whose support covers the box), and the resolved prior’s source (validate_prior_transform_compat).
13. Utility Commands
13.1 camdl fit table
Walks a results tree and renders one row per run — its run id, method, convergence verdict, best log-likelihood and its type, age. Rows of one problem share a fit_id; the JSON row carries the full run_id (table_row schema v4, in which a non-finite number is the string "NaN", "inf" or "-inf"), the CSV appends it as the last fixed column. The <ROOT> argument is required.
$ camdl fit table results/fits
fit_id run label stem method methods converged best_ll ll_type age
---------------------------------------------------------------------------------------------------------------------------
4dadedae 9df5b6b9cf23 <unlabelled> fit_pgas pgas pgas yes — complete_data 15s
Read-only by default: every cell is recovered from the on-disk run.json and the leaf’s outputs. One flag breaks that rule — --quantity <NAME> may derive a value on demand, running fit predict --horizon free_forward for any fit that has not been predicted yet, and writing that fit’s quantities/ outputs. Optimizer fits have no posterior cloud, so their cell renders —.
Filters: --converged, --gate-failed, --with-method if2|pgas|pmmh, --model <HASH_PREFIX>, --hash <HASH_PREFIX>, --since-seconds <N>, --label-pattern <GLOB>. --baseline <HASH_PREFIX> picks the fit that the Δ columns are measured against (default: lowest hash in the surviving cohort). --exclude-chains <IDS> applies only to derived --quantity cells and always warns, because post-hoc chain exclusion biases the posterior toward the retained mode.
--format text|json|md|csv. The JSON document is schema-pinned; each row carries considerably more than the text view:
schema, fit_id, fit_hash, label, stem, model_identity, methods, method,
config_diff_from_baseline, converged, gate_verdict, best_loglik, loglik_type,
max_chain_agreement, max_rhat, acceptance_rate, ess_at_mle, ess_posterior,
ess_per_iter, ess_per_sec, params, delta_ll_vs_best, age_seconds, created_at,
stale, stale_reason
gate_verdict is the method’s convergence verdict, the one fit summary and a from_mle / from_posterior source check read:
| value | method | meaning |
|---|---|---|
pass |
if2, nl-* | every leg passes |
fail_a |
if2 | some estimated parameter’s tail chain agreement  (initial-state ones included) is at or above the threshold |
fail_db |
if2, nl-* | the chains’ log-likelihoods spread past the decibans floor |
fail_both |
if2, nl-* | both |
incomplete |
if2 | no leg failed, but some  is not finite (within-chain variance collapsed), or the run lost started chains (n_good_chains < n_chains; chain_starts.tsv marks them skipped), so the legs judged the survivors only: not a pass, and a starts-source check refuses it |
fail_chain_agreement |
nl-* | the converged chains’ estimates disagree |
fail_status |
nl-* | a chain ran out of evaluations or failed |
not_assessed |
if2, nl-* | nothing was assessed: the run asked for one chain (one converged nl-* chain), so there is nothing to compare between chains; or an nl-* leaf that predates the recorded optimizer verdict (re-run it with --force). Not a pass, though a starts-source check accepts it. A multi-chain IF2 run left with one chain is incomplete, not this |
For an optimizer method converged is true exactly when gate_verdict is pass. A sampler row carries gate_verdict = "n/a", and converged is its R̂ verdict (reported, and below the threshold).
fit table tabulates only completed method leaves whose method produces a tabulated result. A leaf that is still running or incomplete, and a pfilter leaf (a likelihood evaluation with no estimate to tabulate), get no row and no message. A completed leaf whose result cannot be read is reported as a warning: line and skipped; with --quantity, such a leaf is an error instead, because dropping it would answer for fewer runs than were asked about.
To enumerate runs of any kind without the per-fit projection, use the generic run browser: camdl list --kind fit. For one fit’s full interpretation, use camdl fit summary <handle>.
13.2 camdl fit diff
Compares two fit configs. Both must parse as complete v2 configs ([estimate] is required), so this is a config-to-config diff, not a text diff.
$ camdl fit diff fit_a.toml fit_b.toml
diff: fit_a.toml → fit_b.toml
Model:
/work/sir.camdl → /work/sir_waning.camdl (compiled model differs)
Data:
cases: content differs
Parameters:
beta: [estimate] → [fixed] = 0.31
omega: [estimate] (added)
sigma: [fixed] = 0.2 → [estimate]
gamma: [fixed] = 0.3 (removed)
N0: [fixed] 10000 → 20000
Method:
chains: 1 → 4
The model is compared by its compiled identity, the same one a fit records, so two paths to the same model read (same compiled model); a .camdl source is compiled with camdlc to get it, and a model that does not load is reported as not compared rather than as unchanged. Data is compared by the content hash of each stream the config binds. Parameter-side coverage: parameters entering or leaving [estimate] and [fixed] (paired as a move when one does both), changed fixed values, changed bounds (including the omit↔︎explicit transition, which is meaningful because omitting means “fall back to the model file”), and changed priors.
Method-side coverage is every key the [method] serializes to, one line per changed key in key order, with nested tables flattened to dotted paths (gate.a_thresh) and arrays diffed whole; a key one side lacks reads null. A file with no [method] against one with reads [method]: (new) pgas / [method]: (removed):
$ camdl fit diff if2_a.toml if2_c.toml # particles 1000→2000, cooling 0.70→0.95
diff: if2_a.toml → if2_c.toml
Model:
(no model changes)
Data:
(no data changes)
Parameters:
(no parameter changes)
Method:
cooling: 0.7 → 0.95
particles: 1000 → 2000
Both halves are a rendering of the same typed diff fit table --format json reports under config_diff_from_baseline (ConfigDiff in rust/crates/cli/src/fit/config_diff.rs).
13.3 camdl fit new
Copies a fit config to a new path and injects a [provenance] block. It does not change [method], write a starts that names the source fit, or fill in reason.
$ camdl fit new --from fit_pgas.toml fit_v2.toml
[provenance] derived_from = "fit_pgas.toml"
hint: to warm-start the derived fit from …/results/fits/fit_pgas-4dadedae, write under its [method]
starts = { from_posterior = "@<label>" } # one draw per chain
starts = { from_mle = "@<label>" } # every chain at its estimate
(a handle is @label, a fit-id prefix, or the leaf directory; `camdl list` shows them)
created fit_v2.toml
The written file is the source verbatim with:
[provenance]
derived_from = "fit_pgas.toml"
reason = ""inserted before the first table. Refuses to overwrite an existing destination. If the source already has a [provenance] block it says so and leaves it alone.
The hint is the whole of the chaining story. The derived file names its source by handle under [method], and the handle resolves when the derived file runs (§6.6):
[method]
algorithm = "pgas"
…
starts = { from_posterior = "@<label>" }or per run, as camdl fit run … --starts from_posterior=@<label>.
13.4 Reading results back
There is no camdl summarize. Four commands cover the ground:
| Command | Answers |
|---|---|
camdl list [ROOT] |
What runs exist? Filter by --kind sim|fit|profile|pfilter|survey|ensemble, --model, --scenario, --since 1h, --parent <HASH>. --format json. |
camdl show <TARGET> |
Full metadata for one run, by short hash prefix or path. |
camdl cat <TARGET> |
The run’s trajectory TSV; --stream <NAME> selects an observation stream or a named artifact (event_log.tsv, reactive_log.tsv). |
camdl label <HASH> <LABEL> |
Name a run so you can find it again. 1–64 chars matching ^[a-zA-Z0-9 ,._-]+$; refuses a still-running fit, an ambiguous prefix, a fit id (a problem folder, not a run), a label another fit run holds, and relabelling a fit run. |
Per-scenario summary statistics over a trajectory tree (peak, time-of-peak, final value, integral) are not a camdl command. Declare them in the model as quantities { } and emit with camdl simulate --quantities-out <dir>, which writes <dir>/quantities/<name>.tsv plus <dir>/quantities.json — a bare value per leaf for a fixed-parameter run, banded quantiles for a --draws/--replicates/--seeds run. Quantities are a regenerated sidecar and never part of run identity. A model that declares quantities but is run without the flag prints a note and skips them.
13.5 camdl mre
Packages the full input closure of a fit or a forward simulation — model, read() tables, data, params — into a shareable .tar.gz:
camdl mre fit fit.toml # → fit.mre.tar.gz
camdl mre fit fit.toml --no-data -b bug.tar.gz # structure only, no data values
camdl mre simulate sir.camdl --params p.toml --seed 42-b/--bundle names the output (-o keeps its trajectory-mirror meaning on mre simulate). --no-data emits column schema, row counts and time range with no values, for sensitive data.
13.6 camdl docs
Guides embedded in the binary — offline, version-matched to the binary that prints them:
camdl docs # list topics
camdl docs inference # print one
camdl docs --search rhat # find where a term is discussed
camdl docs --json # machine-readable index (for agents)Topics: agents, getting-started, language, language-changes, changelog, inference, diagnosing-fits, workflow, fit-toml, concepts, features, backends, data, dates, debugging, mre, model-comparison.
14. Observation Semantics
An observations { } stream projects simulator state into the scalar projected value that the likelihood scores. This section states, for each projection kind, what is read, when, and what resets afterwards — the three facts that decide what a likelihood term actually means.
14.1 Projection kinds
There are five IR projection variants (ir::observation::Projection), classified into two temporal kinds (Projection::temporal_kind). The classification is derived from the variant, never stored — an independently-stored kind could only ever disagree.
| DSL surface | IR variant | Temporal kind |
|---|---|---|
incidence(tr) on an unstratified/one-cell transition, or incidence(tr[a]) |
cumulative_flow |
Interval |
sum(a in dim, incidence(tr[a])) |
cumulative_flow_sum |
Interval |
X[cell], or a bare X on an unstratified compartment |
current_pop |
Instant |
sum(X) on a stratified compartment (a bare X there is E280), or a bare X pooling only via stages |
current_pop_sum |
Instant |
any expression over state — X1 + X2, I / (S+I+R), prevalence(of = I[a], among = N_local[a]), a let reference |
derived_expr |
Instant |
Verified against the compiler on a two-stratum (age = [child, adult]) SIR:
projected = sum(I) → current_pop_sum ['I_child', 'I_adult']
projected = I[child] → current_pop I_child
projected = prevalence(of = I[child], among = N_local[child])
→ derived_expr pop I_child / binding_ref N_local_child
projected = sum(a in age, incidence(infection[a])) → cumulative_flow_sum ['infection_child', 'infection_adult']
projected = I[child] + I[adult] → derived_expr
- Interval (incidence) — the sum of per-transition flow counters accumulated over the period the row is declared to cover, read at the period’s stop and reset afterwards (
StreamProjection::resets_after_observation,sim/src/inference/multi_stream_obs.rs). The period is[start, stop)from the stream’scoversform or its window columns; the accumulator also resets at the first period’s start, so a warm-up before the first row is never scored. - Instant (prevalence / derived) — a function of the state vector read at the observation instant. No accumulation, no reset; each observation is independent of the previous one.
Reset is per-stream and per-cadence: with several streams on different schedules, only the Interval streams scheduled at the current union index are zeroed. A hole in the data (an NA cell) contributes no likelihood term but still resets, because the grid time is present.
A bare incidence() over a stratified family is a hard error, not a silent sum (E280) — on an un-indexed stream, and on an indexed one, where it would score every row against the pooled total and the hint names the stream’s binder (incidence(infection[p])):
$ camdlc check strat_probe.camdl # projected = incidence(infection)
error[E280]: observation 'cases' is un-indexed, but `incidence(infection)` would silently
sum all 2 strata of 'infection' and apply reporting uniformly
Pooling across strata is a modelling decision, so the compiler makes you state it — either projected = sum(a in age, incidence(infection[a])) with one reporting rate in the likelihood, or an indexed stream cases[a in age] { … } with rho[a]. The same rule covers state: a compartment written bare over a population stratum (projected = I on an age-stratified I, or a let whose body does so) is also E280, and sum(I) or sum(a in age, I[a]) states the pooling. Residence structure created by via may still be omitted — a bare I on a staged, otherwise unstratified compartment pools its stages and compiles. In model dynamics a bare stratified name is unchanged: it expands to PopSum.
A stock as a count needs no operator; a stock as a proportion is prevalence(of = X, among = Y). The proportion form is exactly X / Y — the same resolution and the same derived_expr — plus three checks plain division does not get: every cell of reads must be one among reads (E357; vacuous when among reads no cell, such as a parameter or a table), both sides must collapse the same dimensions (E358), and neither side may read a flow (E359). The one-argument prevalence(X) and the multi-argument prevalence(X1, X2) are removed (E356): they returned a count, exactly X or X1 + X2, under a name that promised a proportion. A state expression over several compartments is written as one — projected = Y1 + Y2 — and the compiler-generated forms for a via erlang (an ESum over the stage axis) or via hyper_erlang (an Add-chain over branch-stage cells) compartment lower the same way.
incidence projects exactly one flow: several arguments is E203, and none is E250 (gh#669). A compartment population is an expression leaf, so state can be added as an ordinary expression; a flow has no expression leaf naming it, so flows are added term by term (incidence(a) + incidence(b)).
current_pop / current_pop_sum resolve to integer compartments (StreamProjection::from_ir, multi_stream_obs.rs). A prevalence projection naming a real-valued compartment is refused with a message saying so; use a derived_expr (which reads both state kinds) instead.
Only derived_expr carries a projection_state_grad (∂projection/∂compartment). The three selection variants are linear projections and need none; the gradient-based samplers refuse a derived_expr whose projection gradient is missing rather than scoring a silent-zero chain-rule factor.
14.2 Snapshot timing
“State at t” means three different things depending on which path is running. Getting this wrong changes what your data means, so it is stated per-path rather than per-backend.
Inference (particle filter, IF2, PGAS, PMMH) — exact landing at t. The filter builds an Exact-policy timeline (ExactInferenceTimeline::build, intervention.rs, called from particle_filter.rs) whose substep is dt.min(next_boundary − t), so the walk lands exactly on the observation time even when it is off the dt grid. There is no snapping to a step boundary. Scoring happens after the window walk completes, at the landed state. A scheduled effect whose fire time is off the dt grid is refused loudly at timeline construction rather than being silently re-tiled.
ODE inference — the recorded output snapshot must land on the obs time. The deterministic likelihood (compute_ode_loglik, inference/ode_loglik.rs) matches each observation to a trajectory snapshot within 1e-9. It is not dense-output interpolation. If the model’s output schedule does not contain an observation time, the fit errors:
ODE trajectory has no snapshot at obs time <t> (snap.t = <s> overshot it). The
model's [output] schedule must include every observation time; declare an
explicit `output { at = [...] }` block aligned to the data, or ensure the
regular schedule's step divides obs intervals.
The value path reads snap.int_state.counts, which the ODE backend produced by rounding (x.max(0.0).round() in to_states, sim/src/ode.rs). The gradient path (nl-lbfgs) reads the unrounded continuous state instead (ode_loglik_and_grad, inference/multi_stream_obs.rs). At small counts the two therefore score slightly different likelihood surfaces for the same model and data; at large N the difference is sub-nat.
Forward simulate --obs — the recorded trajectory output grid. Synthetic observations are projected from the trajectory snapshots, not from the integrator: snap_at (cli/src/main.rs) returns the last snapshot at or before the observation time, and incidence is the difference of cumulative flow between consecutive such snapshots.
Consequence: every observation time must be a recorded output time, and a run where one is not is refused (gh#589). Reading the earlier snapshot would put a flow’s whole interval on one boundary and zeros elsewhere, and a stock would step, while the file still carried the requested timestamps. So, with a weekly output grid and a daily emit_schedule:
output { trajectories { every = 7 'days } }
observations {
prev { projected = I emit_schedule = every 1 'days … }
inc { projected = incidence(infection) covers = closing_at(time, 1 'days) emit_schedule = every 1 'days … }
}simulate stops before simulating, with an error of this form (the first off-grid time and its two neighbours are named):
error: observation stream 'inc' needs a value at t = 1, which is not a recorded output time (nearest recorded: 0 and 7).
Required: every observation time must also be an output time.
--output-every N is subject to the same check, so a flag that reads as presentational still constrains the observation cadence. Set the trajectory output cadence at least as fine as the finest emit_schedule (or, when the times come from a data file, fine enough to include them; fit predict records snapshots at the data times, §2.2.2). The output schedule defaults to every 1.0 days, independent of dt, so sub-unit observation times need an explicit output { trajectories { every = … } }.
14.3 Interaction with scheduled interventions
If a scheduled intervention fires at the same time as an observation, the projection reads the post-intervention state. The data was generated in a world where the intervention had already fired; scoring against the pre-intervention state would deterministically bias the posterior against any scenario that correctly represents it.
When each effect fires is decided once per run by the effect plan (rust/crates/sim/src/effect_plan.rs, EffectPlan::build), on the backend’s clock: chain-binomial and every fit that steps it (pfilter, PMMH, IF2, PGAS) fire on the run’s grid t_start + k·dt, k = ⌊(t − t_start)/dt + ½⌋; the ODE and Gillespie fire at the declared time (language spec §13.2). Effects at t_start apply to the starting state before the t_start row is recorded or an observation there is scored (§13.10).
The ordering at an instant is fixed in one shared dispatch seam (Schedule::arrive), not by each backend: effects before output, each due effect boundary’s batch applied once, then all coincident outputs. On the inference path the equivalent guarantee comes from the substep walk: a substep that lands on one of the plan’s firing instants fires that instant’s batch inside process.step, and scoring happens after the window walk. Forward chain-binomial fires the plan’s batch for each substep inside step_one and records the output afterwards. (Historical: an earlier chain-binomial loop fired interventions twice per scheduled time; see docs/dev/incidents/2026-04-17-chain-binomial-double-fire.md.)
14.4 Likelihood families
Eight families (ir::observation::Likelihood): poisson, neg_binomial, normal, binomial, beta_binomial, beta, bernoulli, zero_inflated_neg_binomial.
Four facts about how they score that are not obvious from the names:
normal(...)is a discretized count likelihood (pomp / He et al.), not a continuous Normal PDF. It rounds the observation to the nearest non-negative integer and integrates the density over[y − 0.5, y + 0.5]. A clearly fractional observation logs a one-time warning (eval_likelihood_resolved,sim/src/inference/obs_model.rs). For a continuous outcome this is the wrong family.- Every count family rounds the observation.
poisson,neg_binomial,binomial,beta_binomialall applyy.round().max(0.0)before scoring (the log-pmfs inobs_loglik.rs, e.g.negbin_logpmf). A per-1000 rate or a scaled series fed to a count likelihood is silently rounded —0.4 casesscores as0. bernoulli(p = …)thresholds at 0.5. Any observed value> 0.5counts as a success (eval_likelihood_resolved,obs_model.rs), so a count column accidentally bound to a Bernoulli stream reads as “detected” for every non-trivial value.beta(mean = …, concentration = …)requiresx ∈ (0, 1)strictly (beta_logpdf,obs_loglik.rs). An observed proportion of exactly 0 or 1 — a common outcome for small denominators — scores−∞and takes the whole fit with it. Usebeta_binomialwith the denominator asnwhen the data iskofn.
Pairing guidance:
- Incidence:
neg_binomialorpoisson, with the reporting rate in the mean (mean = rho * projected). Support on ℤ≥0; overdispersion natural. - Prevalence, single compartment:
binomial(n = N, p = projected / N)when the denominator is known and observed;poissonfor largeN.neg_binomialis valid but its dispersion means something different here than it does for incidence. - Prevalence as a fraction (projection ∈ [0, 1]):
betafor a directly observed proportion,beta_binomialfor ak-of-ncount.
An incidence row whose period opens before the run is refused. Every incidence stream states what its rows cover (covers, or window_start / window_stop columns — docs/camdl-data-spec.md), so each row’s period is known at load. A row whose period opens before t_start would be scored against flow that was never simulated; the loader (pfilter::stream_times_for) refuses it before the filter runs, naming the period’s start and t_start, and the fix is to move simulate.from back or drop the rows the model cannot cover. Under closing_at(time, Δ) a row labelled t_start is exactly this case. A hole (NA) whose period opens before the run carries nothing to score and is dropped instead. There is no zero-width bin to reason about: a row’s period is never inferred from its neighbours.
The startup block prints the pairing — for two of the algorithms. pgas and pmmh print each stream’s projection kind and likelihood family before running, and warn when a neg_binomial is paired with a snapshot projection:
── method: pgas ──
2 observation streams: detection, weekly_cases
observations (2 streams):
✓ weekly_cases incidence(infection) NegBinomial
✓ detection I Bernoulli
camdl pfilter, if2, mh, and the NLopt optimizers print only the bound-stream line (pfilter: bound streams: detection(bernoulli), weekly_cases(neg_binomial)) — the projection kind is not shown there. Read the model, or run camdl inspect, when you need to confirm a pairing on those paths.
Appendix A: CLI Reference
Generated from --help at every level of the real command tree.
Global options
Accepted on every subcommand:
--verbosity <LEVEL> error/warn/info/debug/trace; overrides RUST_LOG (default warn).
--progress plain auto-bumps to info
--progress <MODE> auto (default) | pretty | plain | none
--no-progress shorthand for --progress none; wins over --progress
--no-ir-cache recompile the .camdl instead of reusing ~/.cache/camdl/ir
(or $CAMDL_IR_CACHE_DIR)
--no-licm disable loop-invariant code motion (on by default). Value-
preserving, but it changes the compiled IR, so a --no-licm
run re-keys the IR cache and run identity. = CAMDL_NO_LICM=1
-h, --help print help
-V, --version print version (top level only)
The command tree
camdl
├── simulate MODEL forward simulation (single run, ensemble, sweep)
├── fit
│ ├── run CONFIG run the [method] of a fit.toml
│ ├── summary FIT R̂ / gate verdict / MLE table for one fit
│ ├── diff A B compare two fit.toml configs
│ ├── table ROOT one row per fit under a results tree
│ ├── new --from A DEST derive a new fit.toml
│ ├── predict [FIT] posterior-predictive (predicted vs observed)
│ └── methods list supported (algorithm, backend) pairs
├── pfilter MODEL bootstrap particle filter at fixed θ
├── profile MODEL profile likelihood over a parameter grid
├── survey MODEL likelihood-landscape LHS diagnostic
├── data
│ └── split FILE train / holdout split of a data TSV
├── list [ROOT] browse cached runs
├── show TARGET full metadata for one run
├── cat TARGET emit a run's trajectory or observation output
├── compare [PATHS…] compare fits by prequential scores
├── label HASH LABEL set a run's display label
├── check … parse + type-check (delegates to camdlc)
├── inspect … print model structure (delegates to camdlc)
├── render … LaTeX or display JSON (delegates to camdlc)
├── lineage
│ ├── realize EVENT_LOG event log → line list
│ ├── tree LINE_LIST line list → sampled transmission tree (Newick)
│ ├── sojourn LINE_LIST dwell-time distribution in a compartment
│ └── cohort LINE_LIST per-window event summary
├── mre
│ ├── fit CONFIG bundle a fit's input closure
│ └── simulate MODEL bundle a forward-simulation reproduction
├── docs [TOPIC] embedded guides (offline, version-matched)
├── check-update query GitHub for a newer release
└── dev developer & maintenance commands
├── reindex [ROOT] rebuild <root>/index.json
├── eval MODEL --expr E evaluate time-dependent expressions
├── compile … .camdl → IR JSON (delegates to camdlc)
└── doctest … compile camdl blocks in Markdown (delegates to camdlc)
camdl simulate MODEL
Model inputs
--params FILE parameter TOML (repeatable)
--param NAME=VALUE single override (repeatable)
--param-vec PREFIX=FILE indexed-parameter vector from a keyed TSV (repeatable)
--table NAME=FILE external table for table-lookup expressions
Scenario
--scenario NAMES named scenarios, comma-separated or repeated;
each is its own cell. Conflicts with --enable/--disable
--enable NAME enable an intervention (repeatable)
--disable NAME disable an intervention (repeatable)
Engine
--backend gillespie|chain_binomial|ode (default chain_binomial)
--dt DT step for discrete-time backends (default 1.0)
--integrator rk4|rk45 ODE method override; tolerances live in the model's
`simulate { integrator = rk45 { atol, rtol } }`
--allow-degenerate-rates restore legacy silent-zero on numerical collapse
Run window
--to SPEC override the horizon (simulation.t_end). SPEC = a
model-time number, a date (date("YYYY-MM-DD") or bare
YYYY-MM-DD), or an observation anchor with an optional
offset ("last_obs + 8 weeks"). Anchored forms need
--fit for the observed times. A scenario declaring a
different horizon is an error
--init-state FILE|fit start from an inferred state at the last observation
time instead of init { }. The origin becomes t_start,
and must coincide with an output-emit time.
chain_binomial only. Two sources:
FILE — a `pfilter --save-final-state` particle
ensemble at the filter's ONE θ. Its header carries
the origin; replicate i restores particle row i, so
--replicates must equal the row count. Conflicts
with --draws
fit — the --fit run's paired (θ_i, X_i(T)) posterior:
draw i restores the terminal row of its OWN saved
latent path under its OWN θ_i. Requires
--draws posterior; runs over the subset of draws
that have a saved path, and reports that count
Ensemble
--seed N single seed (default 1; env CAMDL_SEED)
--seeds SPEC range "1:100" or list "1,2,42"; conflicts with --replicates
--replicates N stochastic replicates per parameter point
--draws SOURCE path to a params TSV | "uniform" | "prior" | "posterior"
--fit PATH companion for --draws: fit.toml (prior), fit results
dir (posterior), or [fixed] backfill (file)
-n, --n-draws N number of draws (required for uniform/prior)
--draws-out PATH write the sampled parameter vectors as a TSV
--sweep PARAM=GRID the DESIGN axis (repeatable): a comma list,
lin(min,max,n) or log10(min,max,n); several flags form
the name-sorted Cartesian product. Refused with --draws,
with a --param on a swept parameter, and with a scenario
that sets or scales one (§5.5)
--sweep-file FILE design points from a TSV: header = parameters, one row
per point. Refused with --sweep and --draws
--parallel N Rayon threads for the simulation phase; output is
byte-identical whatever the value (0 = all cores;
env CAMDL_PARALLEL; §4.4)
Trajectory output
-o, --output FILE plain-TSV mirror IN ADDITION to the store leaf
(env CAMDL_OUTPUT)
--stdout stream to stdout, write no store leaf. Single-cell
only; conflicts with --seeds/--replicates/--draws,
-o and --obs*
--output-every N one row every N time-units, overriding output { every }
--no-flows drop every flow_* column
--columns COL,... restrict trajectory columns to this allow-list
--dates add a calendar `date` column (requires model `origin`)
Observation output
--obs FILE synthetic observations, all streams, one TSV
--obs-dir DIR one TSV per stream
--obs-only FILE like --obs, suppress trajectory output
--obs-only-dir DIR like --obs-dir, suppress trajectory output
--design-from FIT_TOML simulate on the observation design that fit config
binds — its observed labels, each row's own period,
its NA holes — instead of on `emit_schedule`.
Requires --obs-dir, --obs-only-dir or --projected-out;
θ from --params or --draws (one ds_NN/ per draw)
--projected-out DIR with --design-from: write each stream's projected
value (before observation noise) as one TSV per
stream, in column `projected`; a sidecar outside run
identity. Usable with or without --obs-dir
--emit-every N | NAME=N override `emit_schedule`, in model time units, for
every stream or one by its observation-block label
(repeatable; the two forms are exclusive). Only a
recurring `every N` cadence can be overridden; a
stream declaring `at [...]` is refused by name.
Refused outright when the run emits nothing
Other artifacts
--quantities-out DIR emit quantities { } as <dir>/quantities/<name>.tsv +
quantities.json. A multi-scenario run writes one band per scenario
--quantities FILE with --quantities-out: report with the `quantities { }`
block in FILE (a .camdl file holding only that block)
instead of the model's own; tables land in
<dir>/quantities-<key>/, keyed by FILE's contents
--event-log [FILE] record the lineage event log into the run leaf; pass a
PATH to also mirror it. Single-run only
--format parquet|tsv event-log format (default parquet); --tsv is shorthand
--reactive-log PATH mirror the reactive firing log (leaf copy always written
when a reactive policy was active). Single-run only
Store
--output-dir DIR store root (default ./results; env CAMDL_OUTPUT_DIR)
--force re-run even if cached output exists
--label TEXT display label, ^[a-zA-Z0-9 ,._-]{1,64}$
--dry-run print the resolved run plan without simulating
--cas no-op, accepted for compatibility (CAS is the default)
There is no --resume flag: a multi-cell run reads back the cells the store already holds by default, and --force overrides it. camdl fit run --sweep sweeps a [fixed] parameter of a fit (§7.3).
camdl fit run CONFIG
--seed N RNG seed (default 1)
--parallel N Rayon thread cap; bit-identical regardless of value
(default 0 = all cores; env CAMDL_PARALLEL)
--force re-run; the existing leaf is quarantined
--resume BASE_REF extend a completed PGAS/PMMH run from a base run
(run_id prefix or leaf path); conflicts with --force
--sweep NAME=SPEC Cartesian sweep over a fixed parameter (repeatable).
SPEC = V1,V2,... | lin(min,max,n) | log10(min,max,n)
--label TEXT the run's label; `@label` names this one run.
Refused if another run holds it or this
invocation writes several runs
--emit-every N | NAME=N override a `[synthetic]` fit's observation emission
cadence (same grammar as `simulate --emit-every`);
refused on a fit with no `[synthetic]` block; the
fit re-keys
Chain starts
--starts SPEC uniform_unconstrained | lhs | uniform | from_prior | single
| from_posterior=<handle> | from_mle=<handle>
| from_params=<toml>. Default: from_prior when every
estimated parameter declares a prior, else
uniform_unconstrained
--allow-nonconverged-source start from a from_mle / from_posterior source whose
stored verdict is not converged
Post-fit audit
--no-dt-check skip the Richardson dt-convergence check at θ̂
--dt-check-strict strict warning thresholds (0.5 nats chain_binomial,
0.1 nats ode) instead of the routine 2.0 / 0.5
--dt-check-halvings N default 2 (dt, dt/2, dt/4)
Per-algorithm overrides
--decibans-thresh DB gate's inter-chain loglik-spread floor
--cooling-target-iters N IF2 cooling target
--max-tree-depth N PGAS NUTS depth ceiling
--trajectory-warmup N CSMC-only sweeps before parameter updates
--csmc-sweeps-per-nuts N CSMC trajectory updates per parameter update
--n-trajectories N posterior trajectories saved
--diagonal-mass dense_mass = false (one-way)
--no-nuts use_nuts = false — MH-within-Gibbs for θ|X (one-way)
--no-ancestor-sampling ancestor_sampling = false — plain particle Gibbs
(one-way; the run stores under its own address)
--binomial btpe|btrs PGAS chain-binomial sampler (default btpe); not
bit-compatible across samplers, so it keys identity
--no-adapt adapt = false — lock proposal SDs (one-way)
--adapt-start N step at which proposal-SD adaptation begins
--record-ancestry record ancestor indices for smoothing paths
--record-prequential record per-step predictive samples for `camdl compare`
--stage, --init, --posterior, --mle, --params, --survey-path, --survey-top-k, --allow-nonconverged-scout, --starts-from and --init-method are parsed only to be refused, each naming its replacement (§7.1).
camdl fit — the other subcommands
camdl fit summary FIT
FIT is a handle: @label, a fit-level hash prefix, a results directory, or a fit.toml
--format text|json|md|latex (default text)
--params-only print only θ̂ as a flat params TOML (pipeable)
--no-color disable ANSI colour (NO_COLOR is honoured regardless)
--strict exit non-zero on provenance mismatch; auto-on when CI=true
--exclude-chains IDS recompute diagnostics over a chain subset (view only;
always warns; incompatible with --params-only)
camdl fit table ROOT (see §13.1 for filters and formats)
camdl fit diff A B (see §13.2)
camdl fit new --from A DEST (see §13.3)
camdl fit methods list (algorithm, backend) pairs with stability tiers
camdl fit predict [FIT]
--fit FIT same handle grammar as `fit summary`
--stream NAME one logical stream, or an expanded leaf name
--scenario NAMES prospective scenario overlay (repeatable), applied on
top of the fit's own scenario/enable/disable (§11.3).
`fitted` is reserved for the no-overlay row: the
fitted model, the fit's own selection applied
--enable / --disable NAME ad-hoc overlay on the `fitted` row, on top of the
fit's selection; conflicts with --scenario
--sweep PARAM=GRID vary a parameter across the posterior (repeatable).
GRID = list | lin(min,max,n) | log10(min,max,n).
Free-forward only; same param in a scenario is an error
--horizon free_forward|one_step
--n-draws N posterior subsample cap for both horizons (default 200)
--seed N RNG seed for y_rep sampling (default 1)
--exclude-chains IDS drop chain ids (counted from 0) before banding; always warns
camdl pfilter MODEL
--particles N required
--params FILE / --param NAME=VALUE / --table NAME=FILE
--scenario NAME | --enable NAME | --disable NAME
--dt DT step for a model that declares none (default 1);
the model's declared dt is the step, and a
different --dt or --fit [config] dt is refused
--seed N (default 1)
--parallel N Rayon threads, 0 = all cores (env CAMDL_PARALLEL)
--allow-degenerate-rates
--data [NAME=]PATH observation TSV; repeatable. `--data PATH` binds the
single stream (or the one named by --obs); `--data
NAME=PATH` binds by name. Mixing the forms is an error
--obs NAME stream a bare `--data FILE` binds to
--fit PATH fit.toml supplying [data.observations] (used only when
no --data flag was given) and the likelihood objective
(`ic_free`, always used)
--time-format auto|numeric|date
--pf-max-substeps N deterministic per-call compute budget
--replicates N independent filter runs (default 1)
-o, --output FILE default stdout
--trace FILE per-observation diagnostics TSV ("-" for stdout)
--pf-health FILE per-obs ESS + Snyder τ², with the implied
particles-to-avoid-collapse estimate exp(τ²/2)
--save-final-state FILE final particle states
--save-paths FILE smoothing-distribution trajectory samples
--n-paths N how many, for --save-paths (default 1)
--save-filtering FILE per-step particle states and log-weights
--save-prequential STEM {STEM}.tsv + {STEM}.json one-step-ahead predictive
--no-save-samples with --save-prequential, drop per-particle samples
camdl pfilter writes into the content-addressed store under $CAMDL_OUTPUT_DIR (there is no --output-dir flag; --output is the TSV mirror).
When the filter itself fails at the given parameters (its weights collapse, or the state goes negative), pfilter exits 3; every other error exits 1. camdl compare, which derives a prequential by running pfilter at each posterior draw, reads that status: only a 3 is reported as a draw the filter could not score, and any other failure is reported as itself.
With --fit, pfilter scores the likelihood that fit config defines: under the config’s own scenario / enable / disable (the interventions fit run fired), and under ic_free = true it weights and resamples at y₁ but leaves y₁’s term out, so at the fit’s MLE it reports the number fit run maximised (inference spec §3.8). The config’s selection and the --scenario / --enable / --disable flags combine as on survey --fit and profile --fit: either alone selects, the same scenario named by both is one selection, and any other pair is refused — a run scores one problem. A selection taken from the config applies its intervention lists only (§11.3), so --params at the MLE is not overridden by a fit scenario’s set; --scenario beside --params stays refused (gh#194). It prints the objective it scored, for example pfilter: objective: conditional on the first observation (ic_free = true in fit.toml); omit --fit and pass --data for the full likelihood, and refuses an ic_free fit whose model has a deterministic init { }, as fit run does for its pfilter method. Without --fit every observation is scored. The stored leaf records the objective as inputs.likelihood_objective (full or conditional_on_first_observation); the conditional objective is part of the leaf’s identity, and the full objective leaves the key unchanged.
camdl profile MODEL
--sweep NAME=SPEC required, repeatable (2D+ grids). SPEC = V1,V2,... |
lin(min,max,n) | log10(min,max,n). Every value must lie
in the parameter's bounds (the model's, narrowed by
--fit [estimate]); the swept name is recorded `focal`
--particles N IF2 particles per cell: required for if2, refused for nl-*
--fixed NAME=VALUE pin a parameter AND remove it from [estimate] (repeatable)
--fixed-file TOML bulk form of the above (repeatable, later files win)
--iterations N IF2 iterations per grid point (default 50; refused for nl-*)
--n-starts N independent starts per grid point (default 3)
--starts SPEC where each cell's starts are drawn: the rule grammar
`fit run --starts` takes (default `uniform`)
--cooling F IF2 cooling fraction (default 0.95), reached at
--cooling-target-iters as in a fit's `cooling`
(refused for nl-*)
--cooling-target-iters N iteration the fraction is reached at (default 50;
refused for nl-*)
--rw-sd SPEC NAME=SD,... or `auto`: every parameter that is not
swept, pinned (--fixed, --fixed-file, [fixed]) or a
model constant (`let`). An nl-* cell takes names
only (NAME=auto, or `auto`) and refuses an SD
--algorithm NAME per-cell algorithm (default if2); pair with --backend
--backend NAME chain_binomial (default) or ode
--fit PATH fit.toml supplying the estimated set, bounds, the
fixed list and [[simplex_groups]] (honoured by IF2;
every member must be estimated); an nl-* [method]
there also sets each nl-* cell's tolerance and
max_evals (else 1e-4 and 1500)
--seeds SPEC run the whole grid at each seed (multi-seed sensitivity)
--dt X step for a model that declares none (default 1.0);
every cell steps at the model's declared dt, and a
different --dt or --fit [config] dt is refused
--data / --obs / --time-format / --seed / --parallel / --scenario /
--enable / --disable / --table / --allow-degenerate-rates /
--pf-max-substeps (if2 only)
-o, --output FILE accepted, writes nothing: a profile run stores one leaf
per grid cell under `profiles/` and prints no curve
(the curve is a derived view, gh#154); a note says so
--label TEXT
camdl survey MODEL
--fit FIT fit.toml supplying [estimate] bounds and [data].
The model scored is MODEL; the config's [model]
is not read. Mutually exclusive with
--estimate/--data
--estimate NAME=LO:HI inline LHS bounds (repeatable). With --fit, an
estimated parameter whose box has an infinite end
(no `bounds`, no `in [lo, hi]`) is refused
--data PATH inline observation TSV
--fixed NAME=VALUE pin a parameter (repeatable); with --fit, an
[estimate] name is held at VALUE and not sampled
--scenario NAME
--n-points N default 0 = auto: max(1000, 50·d²)
--eval auto|pfilter|simulate auto picks pfilter when the model needs
OVERDISPERSION, simulate otherwise
--eval-particles N (default 200) --eval-replicates K (default 5)
--seed N (default 42)
--render also write an interactive landscape.html
--output DIR output root (default ./results)
--label TEXT --force --parallel N
camdl compare [PATHS…]
PATHS ≥2 when --config is absent. Each is a prequential.json
(or a method leaf holding one), read as-is, OR a fit
handle whose prequential is auto-derived from its θ̂
--explain print the model-comparison methods guide and exit
(the same text as `camdl docs model-comparison`);
takes no PATHS
--config compare.toml [[model]] entries; can also carry baseline/metrics/format
--baseline NAME reference for Δ columns (default: argmax elpd)
--metric elpd,crps,pit_cov90
--format table|md|json (default table)
--allow-mismatched-horizon render even if T_score differs (Δ columns → '—')
--allow-data-mismatch render even if the compared fits were bound to
different observed data; the Δ is then confounded
--draws N mixture size for a Bayesian fit's predictive: one
filter pass per posterior draw, densities averaged
(default 64; 1 = plug-in at the posterior mean)
--replicates N filter replicates per derived fit, combined by
log-mean-exp; their totals give each row's MC SE
(default 5; 1 = no replication and no MC SE)
--allow-mixed-provenance render rows whose provenance kinds differ
(plug-in vs posterior mixture) rather than refusing
--in-sample score every fit in-sample even when it declares a
holdout; stamped in_sample, with the optimism caveat
--particles N for derived prequentials (default 1000, applied uniformly)
--seed N for derived prequentials (default 1)
(a derived prequential filters at the fit's own dt,
read as `simulate --draws posterior --fit` reads it, §3.4)
--exclude-chains [@FIT:]IDS per-fit (`@a:4`) or cohort-wide (`3,4`); mixing the two
forms is rejected. Always warns
--pointwise PATH write the per-observation Δelpd vector as a TSV
Columns: T_score, elpd, Δelpd, LR (= exp(Δelpd), the in-sample likelihood ratio against the baseline on the same scored observations — not an e-value: nothing here establishes a null expectation ≤ 1, so it carries no anytime-valid reading), se(Δ) (paired standard error over pointwise differences), evidence (decibans plus a Jeffreys tier, or within noise when |Δelpd| < 2·se(Δ)), crps, Δcrps, PIT_cov90 (nominal 0.90; below 0.70 triggers an overconfidence warning).
The table and md renderings carry the same footer, and it states only what is specific to that comparison: the scored window and baseline, the conditioning the rows were scored under (in-sample, or held-out tail with the sealed train_end), the sort order, the optimism caveat, the miscalibration flags, each trace’s warnings, the filter-noise MC SE per row, the lag-1 autocorrelation of the per-step Δelpd, and a closing pointer to camdl compare --explain. The definitions those numbers rest on — the Jeffreys tiers and what they do and do not calibrate, se(Δ)’s Newey–West/HLN construction and its small-T unreliability (Sivula, Magnusson & Vehtari, arXiv:2008.10296) — live in that guide (docs/methods/model-comparison.md), not under every table.
The JSON keeps every field: optimism_caveat, evidence_scale_note, se_caveat (populated below 100 scored steps), data_mismatch and data_unchecked, plus per-row provenance, conditioning, lag1_autocorr_delta_elpd, mc_se_elpd, warnings and pit_cov90_warning. It is the machine surface and loses nothing by repeating itself.
Browsing, labelling, data
camdl list [ROOT]
--root DIR alias for the positional; wins when both are given
(default ./results; env CAMDL_OUTPUT_DIR)
--model SUBSTR --scenario NAME --since 1h|30m|2d
--kind sim|fit|profile|pfilter|survey|ensemble|all (default all)
--parent HASH children of a specific parent run (8+ char prefix)
--limit N (default 50) | --all
--format human|json
camdl show TARGET --root DIR --format human|json
camdl cat TARGET --root DIR --stream NAME
camdl label HASH LABEL --root DIR
camdl data split FILE --at-time T | --fraction F, --time-col NAME,
--train PATH, --holdout PATH
Compiler passthroughs
check, inspect, render, dev compile, dev doctest forward every argument verbatim to camdlc; their own --help is short because the flags belong to the compiler. camdlc --help is authoritative.
camdl check FILE.camdl [--no-dim-check] [--json-errors]
camdl inspect FILE.camdl [--summary|--dims|--compartments|--transitions|--tables|
--forcings|--ascii]
camdl render FILE.camdl [--format json] [--expand DIM]
camdl dev compile FILE.camdl [--set NAME=VALUE] [--json-errors] [--no-state-grad]
camdl dev doctest [--gate] FILE.md …
camdl lineage
camdl lineage realize EVENT_LOG
--identity-seed N i.i.d. draw from P(identities | event log) (default 1)
-o PATH line-list output (default line_list.<ext>)
--format parquet|tsv overrides the extension; --tsv is shorthand
camdl lineage tree LINE_LIST
--scheme flat:RATE | stratified:idx=rate,...,default=rate (default flat:1.0)
-o PATH Newick output (required)
--sample-seed N (default 1)
camdl lineage sojourn LINE_LIST --compartment IDX [-o PATH]
IDX is the integer compartment index, not the name. A summary always goes to stderr
camdl lineage cohort LINE_LIST -o PATH
--event infection|<transition id> (default infection)
--window W window width (default 1.0)
--align-first-event align windows to the first matching event, not t=0
camdl dev
camdl dev reindex [ROOT] rebuild <root>/index.json from a fresh walk of every
run.json. The index is only a cache — run.json is the
source of truth — so this is optional; useful after
copying a results/ tree
camdl dev eval MODEL --expr "a,b"
--params FILE / --param NAME=VALUE / --table NAME=FILE
--from T (0) --to T (100) --every DT (1) | --at T1,T2,… (mutually exclusive)
-o FILE default stdout
camdl dev compile / camdl dev doctest (see "Compiler passthroughs")
Sensitivity analysis
Sensitivity analysis (Sobol indices and similar) is not a camdl concern, and camdl generates no space-filling designs. Generate the design in R or Python, run it with camdl simulate --sweep-file design.tsv, and compute the indices over ensemble.tsv (every row carries its point_id and sweep:<param> values) with R’s sensitivity package or Python’s SALib.
Appendix B: Parameter Files
B.1 params.toml — a point m ∈ M
A flat TOML file, one key per parameter, values numeric:
beta = 0.3
gamma = 0.1
sigma = 0.2
rho = 0.4
k = 5.0
N0 = 1000000
I0 = 10Consumed by camdl simulate --params, camdl pfilter --params, camdl profile --fixed-file, camdl fit run --starts from_params=<file>, and fit.toml’s [fixed] from_file. --params is repeatable; later files override earlier ones.
Values must be numbers. A unit-annotated string is a hard error, not a silent parse:
$ camdl simulate model.camdl --params bad.toml # gamma = "0.1 /day"
error: schema mismatch in bad.toml: bad.toml:gamma: expected a number or table section, got String("0.1 /day")
A [provenance] table is skipped, so the mle_params.toml a fit writes can be fed straight back in as a params file without its metadata leaking into the parameter namespace.
Table sections mangle to indexed names. A [<prefix>] section’s keys are joined with an underscore, which is the ergonomic form for indexed parameters:
gamma = 0.1
[beta]
north_child = 0.5
north_adult = 0.3
south_child = 0.6
south_adult = 0.35sets beta_north_child, beta_north_adult, beta_south_child, beta_south_adult. Only one level of nesting is supported; a non-numeric leaf errors naming the section and key.
Values are checked after all resolution: every parameter must be finite and within its declared in [lo, hi], with all violations reported together.
B.2 Indexed parameter overrides
For a model with indexed parameters — beta[region, age], R0[patch] — the bulk-load form is --param-vec PREFIX=FILE, reading a headerless TSV of key<TAB>value. The key is the index suffix, not the full parameter name; the loader forms <prefix>_<key>:
north_child 0.5
north_adult 0.3
south_child 0.6
south_adult 0.35
camdl simulate multi_index_beta.camdl --params p.toml --param-vec beta=beta_vec.tsvFor a multi-dimensional parameter the key is the pre-joined suffix in declaration order (north_child for beta[region, age]), not separate columns. Blank lines and # comments are skipped. An unknown resulting name is a hard error that shows the mangling, so a file that already carries the prefix fails loudly rather than silently doing nothing:
$ camdl simulate multi_index_beta.camdl --param-vec beta=beta_bad.tsv # key `beta_north_child`
error: --param-vec beta: unknown parameter 'beta_beta_north_child'
Matching is by name; row order is irrelevant, and a file may cover any subset of the family’s cells.
Precedence. --param-vec shares tier 5 with --param — the top of the resolution chain — and the same refusal (§1.3, gh#571): a row naming a parameter a selected scenario sets or scales is refused before anything runs, naming the file and the row, since the row would replace the scenario’s value in every cell. On every other parameter the row wins over the lower tiers. The --param-vec unknown-name check runs during full parameter resolution, which --dry-run short-circuits: a --dry-run invocation will not report a bad key.
Footnotes
--output-everyre-keys through the model level, notconfig:util::rematerialize_with_output_everyrewrites the compiled IR to a temp file that both the engine and the identity resolver then load. This is the pattern a model-mutating CLI flag must follow when the flag changes the trajectory.↩︎--emit-everydeliberately does NOT follow that pattern. It changes only the emitted observations, so it keys theobs/child (obs_hash, §10.5) and leaves the simrun_idalone — two cadences are two obs subtrees under one trajectory leaf. Lowering it into the IR would move the model hash afitagainst real data shares, re-keying a fit over a changeemit_schedulecannot make to a likelihood.↩︎