From e8c9e48c0563725b987bf06a429520479e48a6c4 Mon Sep 17 00:00:00 2001 From: Vince Buffalo Date: Sun, 27 Sep 2026 20:20:31 -0700 Subject: [PATCH 1/5] docs(proposal): make a point's coordinate kind a property of the cell Draft. Collision policy, leaf labels and provenance, and quantity banding each decide whether a parameter point is a labelled design condition or a sample from a stated measure, and each reads that off ParamSource -- the CLI flag -- because the cell does not carry it. The flag classifies a hand-authored grid passed via --draws as a sample, and an iid uniform design as a partition in batch run but a band in simulate. Proposes a ParamPlan of a typed design axis crossed with a sample axis (a sample cannot be built without a Measure), CellCoords in place of point_idx + point_overrides with plan_grid as the single constructor, one collision guard keyed on the kind, a manifest summary block naming what a band is over, and fit predict's sweep moved onto the grid. Seeds and resolved values are preserved, so nothing re-keys; IR and goldens are untouched. Five calls are left for the maintainer in section 10. Relates-to: gh#562, gh#572, gh#575 --- ...-09-27-coordinate-kind-design-vs-sample.md | 827 ++++++++++++++++++ 1 file changed, 827 insertions(+) create mode 100644 docs/dev/proposals/2026-09-27-coordinate-kind-design-vs-sample.md diff --git a/docs/dev/proposals/2026-09-27-coordinate-kind-design-vs-sample.md b/docs/dev/proposals/2026-09-27-coordinate-kind-design-vs-sample.md new file mode 100644 index 00000000..20a9fbcd --- /dev/null +++ b/docs/dev/proposals/2026-09-27-coordinate-kind-design-vs-sample.md @@ -0,0 +1,827 @@ +# A parameter point is a design coordinate or a sample, and the cell says which + +- Date: 2026-09-27 +- Status: draft — five calls are open for the maintainer (§10); everything else + is decided inline +- Relates-to: gh#562, gh#572, gh#575, + `2026-06-27-sealed-fit-packets-handles-and-override-algebra.md` (§4), + `2026-08-11-scenario-banding-in-simulate.md` (implemented) +- Builds on: an untracked architecture-review note on gh#562 (2026-08-11). Every + claim this proposal uses from it is restated in §2; the note is not needed to + read this document. + +All code references are against `origin/main` at `2e141e0a`. A reference marked +_verified_ was read at that line; _inferred_ means reasoned from the code but +not executed. + +## Summary + +A run grid in camdl is a product of axes. Some axes index _different things +being compared_ (a scenario, a sweep point); others index _repeated sampling of +one thing_ (a posterior draw, a stochastic replicate). Three decisions depend on +which kind an axis is: whether a scenario may override it, how its cells are +labelled, and whether a quantile may be taken across it. Today none of the three +can read that property off the cell, because the cell does not carry it. Each +decision instead consults `ParamSource` — which CLI flag produced the parameter +points — and the flag is the wrong property: it classifies a hand-authored grid +passed via `--draws` as a sample, and classifies an iid uniform design in +`batch run` as a partition while classifying the same draw in `simulate` as a +sample. + +This proposal replaces `ParamSource` with a `ParamPlan` that holds two typed +axes — a **design axis** and a **sample axis** — and splits the cell's single +`point_overrides` map into a design coordinate and a sample coordinate. +Collision policy, leaf labels and provenance, and banding then key off one +property that the cell carries, set by one constructor. `fit predict`'s sweep +moves onto the grid's design axis so both verbs put their partition coordinates +in the cell. Nothing re-keys run identity; the IR and goldens are untouched. + +## 1. Terms + +A **cell** is one simulation: one scenario, one parameter vector, one stochastic +replicate (`engine::CellSpec`, `engine.rs:70-88`, verified). + +A **partition axis** is an axis whose values index different objects being +compared. Results are grouped by it and reported with it as a column; no summary +pools across it. Scenario is a partition axis. + +A **band axis** is an axis whose values index exchangeable repeated samples of +one object. A quantile band — the `q05 … q95` columns of a `quantities/*.tsv` — +is a summary _over_ band axes, holding partition axes fixed. Replicate (the +process-noise seed) is a band axis. + +A **design coordinate** is a parameter point the user chose as a labelled +condition: a sweep value, a row of a hand-authored grid, a point of a +space-filling design. Design coordinates form a partition axis. + +A **sample** is a parameter point drawn from a stated probability measure over +parameters — the posterior of a fit, a prior, a uniform law over declared +bounds. Samples form a band axis. The measure is what makes a quantile over them +mean something: it is an estimate of a quantile of the push-forward of that +measure through the model. + +A **marginal reduction** summarizes a set of cells treating them as exchangeable +draws from one distribution — a quantile, a mean. A **paired reduction** +combines cells from _different_ values of a partition axis that share a band +coordinate — draw _i_ of the baseline arm with draw _i_ of the intervention arm +— to construct a new random variable before any marginal summary. + +## 2. The problem, restated from the review note + +This section restates the parts of the gh#562 review note that this proposal +builds on, so the proposal stands alone. + +### 2.1 One field carries two meanings, and the cell does not say which + +`CellSpec.point_overrides` (`engine.rs:81`, verified) is the parameter point for +the cell. When the job's `ParamSource` is `Sweep` (`sim_job.rs:175`), each point +is a different model — a partition coordinate. When it is `Draws` +(`sim_job.rs:178-191`), each row samples one model — a band coordinate. The same +loop in `plan_grid` (`engine.rs:168-191`, verified) stamps both into the same +field. No function taking only a `&CellSpec` can compute a correct partition +key. The note observed that this was contained only because sweeps existed +solely in `batch run`, which emits no quantities; that is still true on `main`. + +### 2.2 `fit predict` keeps its sweep coordinate outside the cell + +`fit predict` does not use the grid's sweep axis. It expands `--sweep` itself +(`predict.rs:1589`, verified), then for each sweep point overwrites the swept +parameters in every posterior draw row (`predict.rs:2234-2244`) and runs one +`ParamSource::Draws` job per scenario (`predict.rs:2258-2262`, +`scenarios: vec![sref.clone()]`). The sweep value lives in the loop variable +`sweep_pt` (`predict.rs:2193`) and reaches the renderer directly +(`predict.rs:2301-2304`). Every predict cell therefore looks like a posterior +draw; its partition coordinate is not in the cell. + +### 2.3 The rejected design, and the four reasons + +The gh#562 work designed and rejected a general coordinate type: + +```rust +enum PointKind { Single, Sweep, Draw } // stamped on CellSpec by plan_grid +struct BandKey { scenario: String, sweep: Vec<(String, u64)> } +impl BandKey { fn of(spec: &CellSpec) -> BandKey; } +struct BandSet { bands: IndexMap } // BandSet::push(key, …) +``` + +It was rejected on four grounds (recorded in `2026-08-11` §4): + +1. **A caller-supplied key does not deliver the guarantee.** + `BandSet::push(key, + …)` takes the key from its caller, so a caller passing + one key for every cell pools exactly as before. The shipped alternative — an + accumulator whose only input is a whole cell, `push_cell(&CellResult)`, + deriving its own key (`main.rs:2452-2456`, verified) — gives the caller no + key to pass. +2. **`BandKey::of(&CellSpec)` cannot serve `fit predict`** (§2.2), so a second + public constructor would be needed. +3. **`PointKind` names the wrong property.** It records which CLI flag produced + the points, not whether they carry a measure over which a quantile means + anything. A hand-authored grid passed via `--draws` would classify as a band; + the same grid via a sweep as a partition. `--draws uniform` bands today, + though no belief stands behind it. +4. **It was unreachable:** `BandKey::of` would have returned an empty sweep at + every live call site. + +### 2.4 The collision policy is being keyed on the same wrong property + +A scenario resolves at tier 4 of the parameter resolver and a draw row or sweep +point at tier 3.5 (`params_resolver.rs:48-52`, `:880-903`, `:905-946`, +verified), so a scenario that sets or scales a parameter the point also sets +silently wins. The run-spec audit on gh#572 reproduced three sweep leaves, and +three `--draws uniform` leaves under `--scenario baseline`, each set +byte-identical. + +The existing guard, `check_explicit_draws_scenario_collision` +(`engine.rs:358-405`, verified), fires only for +`ParamSource::Draws { explicit_file: Some(_) }`. `fit predict` has its own guard +for its own sweep (`predict.rs:1628-1653`, verified). `batch run` has none. The +maintainer's policy for gh#572 is: a scenario colliding with a _user-authored_ +coordinate (a sweep, an explicit draws file) is refused; one colliding with a +_generated_ draw (`prior`, `uniform`, `posterior`) is allowed. That fix is in +progress keyed on `ParamSource` — the property reason 3 identifies as the wrong +one. This proposal supplies the property it should key on. (The in-progress +branch was not inspected; its keying is as described in the task that +commissioned this proposal.) + +## 3. What the code shows, beyond the note + +Five facts found while reading the code sharpen or correct the note. + +**`--draws uniform` is an iid sample, not a space-filling design.** +`generate_uniform_draws` draws `lo + (hi − lo)·U` independently per parameter +per row (`main.rs:3852-3889`, verified); parameters without declared bounds are +held at their default. The run spec calls it "space-filling exploration for +model debugging, not a prior" (`docs/camdl-run-spec.md:1138-1140`). So it _does_ +carry a measure — the uniform law on the declared box — and a quantile over it +is a well-defined Monte Carlo estimate of the push-forward of that law. What it +lacks is a claim that the law is anyone's belief. Consequence: "carries a +measure" is necessary for a band but not sufficient to call the band a credible +interval, and the same point set can legitimately be read either way (§4.1). + +**The actual space-filling designs already run as sweeps.** `batch run`'s +`[design.NAME]` blocks (`method = "sobol" | "lhs" | "random"`, +`batch.rs:98-107`, verified) are routed through `ParamSource::Sweep` +(`batch.rs:1870-1900`, verified). An iid uniform design is therefore a partition +axis in `batch run` and a band axis in `simulate` — reason 3 in running code, +not only in principle. + +**No output says "credible" today.** The quantity renderer writes `n_draws` and +`q05 … q95` (`quantity_output.rs:266`), and the manifest carries `schema`, +`calendar`, and per-quantity shape fields (`quantity_output.rs:466-515`); none +of them names what the band is over. The user-visible defect is therefore an +_unlabelled_ band whose meaning depends on the source, not a mislabelled one. + +**Run identity already hashes effective values.** Since PR #941 the `params` +identity level hashes the values the resolver hands the engine +(`batch.rs:1117-1135`, `resolve.rs:227-233`, verified), and the leaf path label +names the point's parameters at the values the cell _ran_ (`batch.rs:1139-1146`, +verified); `run.json` records the same resolved values as `inputs` +(`resolve.rs:237-244`). So gh#572's complaint that identity is keyed on the +requested coordinate is fixed. What still reports the requested value is the +_design_ surface: the dry-run's `sweep override` listing, a design block's +`parameter_points.tsv`, and `sweep:` quantity columns. + +**Common random numbers across design points are accidental and uneven.** The +process seed is `mix_cell_seed(base, point_idx, rep)` (`engine.rs:52-66`, +`util.rs:35-37`, verified) unless seeds are explicit, in which case it is +`seeds[rep]`. `batch run` always passes explicit seeds (`batch.rs:588-589`), so +its sweep points share seeds per replicate. `fit predict` runs one job per sweep +point in which `point_idx` is the draw index, so its sweep points share seeds +per draw. A hand-authored grid in `simulate` mixes its row index into the seed, +so its points share nothing. The typed split in §5 makes this visible; §10 D4 +decides what to do about it. + +Two smaller findings. `ValueSource::SweepPoint` tags a posterior draw's value +`"sweep_point"` (`params_resolver.rs:185-189`, `:200`, `:893`, verified) — the +same conflation at the provenance layer. And the doc comments at +`quantity_output.rs:35-36` and `:476-479` still describe `DesignCoords::none`, +which the gh#562 work deleted. + +## 4. The rule: design coordinates condition, samples are marginalized + +### 4.1 The kind is a declared reading, and only a sample needs a measure + +The working hypothesis was that a point is either a design coordinate or a +sample from a stated measure. The code confirms the dichotomy but refines where +it comes from: the kind is not a property of the numbers. An iid uniform draw +can be read as a sample of the uniform law (to band a quantity over it) or as a +set of design conditions (to plot the quantity against the parameter). A +posterior draw can be read as a sample (the posterior predictive) or as a +condition (one trajectory per draw). What differs is the _question_ — condition +on the point or marginalize over it. + +The asymmetry that makes this safe: **any point set may be read as a design; +only a point set with a measure may be read as a sample.** A sweep grid has no +measure, so it can never be a sample. A generated draw has a measure by +construction. A user-authored file has one only if the user asserts it. The type +in §5 encodes exactly this: `SampleAxis` cannot be constructed without a +`Measure`, and `DesignAxis` needs nothing. + +Each generator has a default reading: + +| Source | Default | Measure (if sample) | May be re-read as | +| -------------------------------------- | ------- | ----------------------- | ------------------- | +| `--draws posterior`, `fit predict` | sample | posterior of the fit | design | +| `--draws prior` | sample | prior (IR or fit.toml) | design | +| `--draws uniform` | sample | uniform on declared box | design (§10 D2) | +| `--draws FILE` | design | — | sample, if asserted | +| `batch run [sweep]`, `predict --sweep` | design | — | never (no measure) | +| `batch run [design.NAME]` | design | — | not offered (§4.4) | + +### 4.2 What each kind implies for the three decisions + +| Decision | Design coordinate | Sample | +| --------------------------------------- | ---------------------------------------------------------- | ------------------------------------------------------------------ | +| A scenario sets or scales the parameter | Contradiction — refused, naming both sources | Counterfactual — allowed; the shadowed columns are recorded (§6.2) | +| Labels and provenance | Reported as a column (`sweep:`) and in the manifest | Not reported per row; the manifest names the measure | +| Marginal reduction (quantile, mean) | Never crosses it | Crosses it | +| Paired reduction | Crosses it, matched on the band coordinate | Supplies the matching coordinate | + +The collision row reproduces the maintainer's gh#572 policy on every input the +policy was written for — sweeps and explicit files are refused, generated draws +allowed — because those inputs default to design and sample respectively. It +differs only where the user declares a reading +(`--draws FILE --draws-kind +sample`, §10 D1), and there it follows the +declaration rather than the flag. + +### 4.3 Marginal reductions stay within a partition; paired reductions cross it + +The rule from `2026-08-11` §1 stands and becomes checkable per cell: + +> A marginal reduction may cross band axes only. A partition axis is eliminated +> only by an explicit measure over its levels (none exists in camdl today) or by +> a paired reduction, which constructs a new random variable per band coordinate +> before any marginal summary. + +`fit/contrasts.rs` is the paired reduction in the codebase: for each forkable +posterior draw it replays every arm under one seed, +`derive_chain_seed(seed, +draw_pos)` (`contrasts.rs:443-455`, verified), and +differences them before banding. Under this proposal the pairing coordinate has +a name: the cell's sample coordinate (and replicate). Two consequences follow, +decided here: + +- A paired reduction across a partition axis is well-defined iff the cells on + both sides share one `SampleAxis` (same rows, same measure) — the band + coordinate then denotes the same parameter vector on both sides. Pairing + across scenarios always satisfies this (a job has one sample axis). Pairing + across design points satisfies it when the design axis is crossed with a + sample axis, as in `fit predict --sweep`. +- Whether a paired reduction also enjoys variance reduction from common random + numbers depends on the seed rule (§3, §10 D4), not on validity. + +### 4.4 Out of scope, recorded so they are not re-raised + +**Weighted designs.** A `[design.NAME]` parameter may carry a `prior` used for +importance weighting by `camdl voi` (`batch.rs:111-117`). A space-filling design +plus importance weights is, in principle, a weighted sample of the prior. camdl +has no weighted quantile path and `batch run` emits no quantities, so design +blocks are design axes, and a weighted-sample reading waits for a consumer. + +**`fit run --sweep`.** A profile-likelihood sweep over fits is a different code +path (a fit per point, not a simulation grid). The gh#572 instance there is +inferred from the code path in `2026-08-11` §9 and is not addressed here. + +## 5. Types + +### 5.1 The plan: two axes and a replicate count + +`ParamPlan` replaces `ParamSource` in `SimulateJob.source` (`sim_job.rs:70`): + +```rust +// sim_job.rs + +/// The parameter-point structure of a job: a design axis crossed with a sample +/// axis, each replicated `replicates` times. Either axis may be the unit axis +/// (one empty point). Replaces `ParamSource`. +#[derive(Debug, Clone)] +pub struct ParamPlan { + design: DesignAxis, + sample: SampleAxis, + replicates: usize, +} + +/// Labelled conditions the user chose. A partition axis. +#[derive(Debug, Clone)] +pub struct DesignAxis { + /// The swept/authored parameter names, sorted. Every point sets exactly + /// these names (a grid point or a file row with a missing column is refused + /// at construction). + names: Vec, + points: Vec, + origin: DesignOrigin, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct DesignPoint { + /// Requested values, aligned to `DesignAxis::names`. + values: Vec, +} + +#[derive(Debug, Clone)] +pub enum DesignOrigin { + Unit, // no design axis + Sweep, // Cartesian grid + File { path: PathBuf }, // --draws FILE read as design + Generated { method: DesignMethod }, // batch [design.NAME]; --draws … as design +} + +/// Exchangeable draws from a stated measure. A band axis. +#[derive(Debug, Clone)] +pub enum SampleAxis { + Unit, + Draws { rows: Vec>, measure: Measure }, +} + +/// What the sample is a sample OF. A `SampleAxis::Draws` cannot exist without +/// one, which is the whole of the design/sample asymmetry (§4.1). +#[derive(Debug, Clone, PartialEq)] +pub enum Measure { + Posterior { fit: String, method: String }, // fit handle + stage label + Prior { source: PriorSource }, // model IR or fit.toml + UniformBounds, // lo + (hi-lo)·U on declared bounds + Asserted { path: PathBuf }, // --draws FILE --draws-kind sample +} +``` + +The fields are private to `sim_job`. The constructors are the only way in, and +each validates its invariant: + +```rust +impl ParamPlan { + pub fn point(replicates: usize) -> Self; + pub fn new(design: DesignAxis, sample: SampleAxis, replicates: usize) + -> Result; // refuses a design name absent from the model +} +impl DesignAxis { + pub fn unit() -> Self; + pub fn sweep(grid: Vec>) -> Result; + pub fn from_rows(rows: Vec>, origin: DesignOrigin) + -> Result; // refuses ragged rows and duplicate rows +} +impl SampleAxis { + pub fn draws(rows: Vec>, measure: Measure) -> Self; +} +``` + +Duplicate design rows are refused because two partitions with identical +coordinates are indistinguishable in a tidy output; a user who wants the same +condition twice wants `--replicates`. + +A design name may also be a sample column: `fit predict --sweep beta` crosses a +`beta` design axis with posterior rows that carry `beta`. The design value wins +for that cell, which is exactly today's behaviour (the row overwrite at +`predict.rs:2239-2241`). §5.4 makes that precedence a resolver tier rather than +a map overwrite. + +### 5.2 The cell: coordinates replace `point_idx` + `point_overrides` + +```rust +// engine.rs + +#[derive(Clone)] +pub struct CellSpec { + run_idx: usize, + coords: CellCoords, + process_seed: u64, + obs_seed: u64, + sim_run: SimRun, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct CellCoords { + pub scenario: ScenarioRef, + pub design: DesignCoord, + pub sample: SampleCoord, + pub rep: usize, +} + +#[derive(Clone, Debug, PartialEq)] +pub enum DesignCoord { Unit, Point { idx: usize, point: Arc } } + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum SampleCoord { Unit, Draw { idx: usize } } + +/// The grouping a marginal reduction must respect. Derived, never supplied. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct PartitionKey { scenario: String, design: Option } + +impl CellCoords { + pub fn partition_key(&self) -> PartitionKey; + /// The index the seed and every per-draw lookup are keyed on (§5.3). + pub fn seed_index(&self) -> usize; +} +``` + +`CellSpec`'s fields become private to `engine`, read through accessors, and +`plan_grid` (`engine.rs:148-193`) is the single constructor. Today there are +four construction sites (`2026-08-11` §4 counted them); the non-test one outside +`plan_grid` is `CasSink::predict_cells` (`batch.rs:1265-1300`, verified), which +re-implements the grid loop for dry-run cache classification. It is changed to +call `plan_grid` on the same job (Stage 0). The two test fixtures +(`batch.rs:2765`, `:2843`) build through a `#[cfg(test)]` constructor in +`engine`. + +`plan_grid` iterates `scenario → design point → sample → replicate`. With either +axis the unit axis, this is today's `scenario → point → rep` order exactly. + +### 5.3 The seed index preserves every current trajectory + +`seed_index` is the sample index when the sample axis is non-unit, otherwise the +design index, otherwise 0: + +```rust +fn seed_index(&self) -> usize { + match (&self.sample, &self.design) { + (SampleCoord::Draw { idx }, _) => *idx, + (SampleCoord::Unit, DesignCoord::Point { idx, .. }) => *idx, + (SampleCoord::Unit, DesignCoord::Unit) => 0, + } +} +``` + +`process_seed_for` receives `seed_index` where it receives `point_idx` today. +Every existing job maps to the same number (verified by reading each +construction site; pinned by Stage 1's test): + +- `simulate --draws posterior|prior|uniform`: design unit, sample index = + today's `point_idx`. +- `simulate --draws FILE` read as design: sample unit, design index = today's + `point_idx`. +- `batch run`: explicit seeds, so `seeds[rep]` and the index is unused + (`engine.rs:59-60`). +- `fit predict --sweep` after Stage 4: sample index = the draw index, which is + today's `point_idx` inside each per-sweep-point job. The `total_runs == 1` + branch (`engine.rs:61-62`) changes from firing per sweep-point job to not + firing on the combined job when there are several sweep points, but + `mix_cell_seed(base, 0, 0) == base` (`util.rs:35-37`; asserted at + `engine.rs:535`), so the seed is the same. + +The same index replaces `point_idx` at the three per-draw lookups that must +follow the sample, not the flat point: the `--init-state fit` row +(`engine.rs:467-471`), `ConditionedSource::per_draw` (`predict.rs:1187`), and +`chain_of_point` (`predict.rs:1262`). The wide-format `draw` column +(`main.rs:2923`) keeps printing `seed_index + 1`, byte-identical. + +### 5.4 The resolver tier 3.5 splits in two, design over sample + +Tier 3.5 (`params_resolver.rs:880-903`) becomes 3.5a (sample row) then 3.5b +(design point), and `ValueSource::SweepPoint` splits into `ValueSource::Sample` +and `ValueSource::DesignPoint`. `SimRun.point_overrides` (`engine.rs:484`) +splits into `sample_overrides` and `design_overrides`, filled by +`build_cell_sim_run` from the cell's coordinates. The resolved values are +unchanged for every current job: no current job has both axes except +`fit predict --sweep`, where the design value already overwrote the sample value +before the resolver saw it. + +### 5.5 Parse at the boundary: where each plan is built + +| Front end | Today (`ParamSource`) | After (`ParamPlan`) | +| ------------------------- | --------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- | +| `simulate` | `main.rs:1772-1785`: `Draws{explicit_file}` or `Point` | generator → `SampleAxis::draws(rows, Measure::…)`; file → `DesignAxis::from_rows` unless `--draws-kind sample` | +| `batch run [sweep]` | `batch.rs:926-935`: `Sweep` or `Point` | `DesignAxis::sweep` | +| `batch run [design.NAME]` | `batch.rs:1900`: `Sweep` | `DesignAxis::from_rows(.., Generated{method})` | +| `fit predict` | `predict.rs:2258`: `Draws{explicit_file: None}` per sweep point | one plan: `DesignAxis::sweep` × `SampleAxis::draws(.., Posterior)` | + +The `explicit_file` field, whose doc comment carries the policy today +(`sim_job.rs:182-190`), is deleted; the policy moves to §6. + +### 5.6 Consumers read the coordinates, never the source + +| Consumer | Reads | Replaces | +| ------------------------------------------------ | ------------------------------------------ | ---------------------------------------------------------------------------- | +| Collision guard (§6) | `ParamPlan` design names vs sample columns | `check_explicit_draws_scenario_collision`, predict's guard, the gh#572 guard | +| `SimQuantities::push_cell` (`main.rs:2456`) | `cell.spec.coords().partition_key()` | `cell.spec.scenario.name()` | +| `PredictiveSink::merge_cell` (`predict.rs:1127`) | same | same, plus the enclosing `sweep_pt` loop variable | +| Quantity `Mode` (`main.rs:1917-1929`) | `ParamPlan::mode()` (§7.1) | `matches!(source, Point { replicates: 1 })` | +| `CasSink::cell_resolve` label (`batch.rs:1143`) | design names ∪ sample columns | `point_overrides.keys()` | +| Seed and per-draw lookups (§5.3) | `coords.seed_index()` | `point_idx` | + +## 6. Collision policy keys on the coordinate kind + +### 6.1 One guard, in `run_job`, for every verb + +`run_job` (`engine.rs:202-203`) calls one guard in place of +`check_explicit_draws_scenario_collision`: + +```rust +fn check_scenario_coordinate_collision(job: &SimulateJob, model: &ir::Model) + -> Result, String> +``` + +For each scenario it computes `scenario_param_footprint` +(`params_resolver.rs: +689-725`, the shared authority both current guards already +use) and intersects it with: + +- the design axis's `names` — a non-empty intersection is a hard error naming + the parameters, the scenario, and the design's origin (the sweep, the file + path, or the design block); +- the sample axis's columns — a non-empty intersection is allowed and returned + as a `ShadowedSample { scenario, params }`. + +Because the guard reads the plan, and `fit predict --sweep` builds a plan with a +design axis after Stage 4, predict's separate guard (`predict.rs:1628-1653`) is +deleted there, and `batch run` is covered without a verb-specific call. The +footprint∩names rule is the one `2026-08-11` §1.1 argues is exact (disjointness +is required for both distinctness and denotation), so the guard is not a stopgap +for a later graded rule. + +The graded rule proposed in the gh#572 comment (warn on partial shadowing, error +on total) is not adopted for design axes. Its motivating example — a scenario +pinning `gamma` while a sweep varies `beta` — has an empty intersection and is +already permitted; what remains in the intersection is a genuine contradiction. + +### 6.2 A shadowed sample is a counterfactual and is recorded, not hidden + +When a scenario pins a sampled column, the arm answers "the quantity under the +scenario, with the remaining parameters drawn from the measure" — a legitimate +counterfactual. It is still a change to what the band is over, so it is written +down: `run_job` prints one stderr line per shadowed scenario, and the quantities +manifest entry for that scenario carries `"shadowed": ["gamma", …]` inside its +`summary` object (§7.2). When a scenario shadows _every_ sampled column, the +band is over process noise alone; the manifest's `summary.over` then lists only +`replicate`, so the band cannot be read as parameter uncertainty (§7.2 derives +`over` per partition, not per job). + +## 7. Banding keys on the coordinate kind + +### 7.1 `Mode` is a function of the plan + +```rust +impl ParamPlan { + pub fn mode(&self) -> Mode { + match (&self.sample, self.replicates) { + (SampleAxis::Unit, 1) => Mode::Point, + _ => Mode::Banded, + } + } +} +``` + +This equals today's predicate (`main.rs:1924-1929`) on every input except a +`--draws FILE` read as design with `replicates == 1`, which becomes `Point` per +design point instead of one band pooled over the file's rows. That is the +intended change (§9, row 1). + +### 7.2 `simulate` partitions by design point and the manifest says what the band is over + +`SimQuantities.by_scenario` becomes +`by_partition: IndexMap`, keyed inside `push_cell` +from the cell as today (rejection reason 1 is not reintroduced — see §8). At +render, each partition becomes one `StackedQuantities::push_group` call whose +`DesignCoords` are built by one function from the key and the plan: + +```rust +impl DesignCoords<'_> { + fn of<'a>(key: &'a PartitionKey, plan: &'a ParamPlan, scenario_axis: bool) + -> DesignCoords<'a>; +} +``` + +`DesignCoords.sweep` already renders `sweep:` columns +(`quantity_output.rs:227-243`) and a manifest `sweep` object (`:480-491`); +`simulate` starts populating it for a design axis. The column prefix stays +`sweep:` for every design origin (§10 D5). + +Each manifest entry gains one object, additive to `camdl.quantities/v1`: + +```json +"summary": { + "over": ["sample", "replicate"], + "measure": "posterior", + "n_samples": 200, + "replicates_per_sample": 1, + "shadowed": [] +} +``` + +`over` lists the band axes actually reduced for that partition; `measure` is the +`Measure` tag, or `"process"` when only replicates are reduced, or absent in +`Point` mode. This is the label gh#575 asked for. A consumer — or a figure +caption — can now say "95 % posterior predictive interval" only when `measure` +is `posterior`, and "quantiles under a uniform law on the declared bounds" when +it is `uniform_bounds`. The `n_draws` column is not renamed here; gh#575's +rename is independent. + +## 8. Why this does not reintroduce the caller-supplied key + +Rejection reason 1 was that `BandSet::push(key, …)` let a caller choose the key. +Nothing here takes a key from a caller: + +- Both accumulators keep the `push_cell(&CellResult)` / + `merge_cell(&CellResult)` shape and derive `PartitionKey` from + `cell.spec.coords()`. There is no method with a key parameter. +- `CellCoords` is set only by `plan_grid`, from the `ParamPlan`; `CellSpec`'s + fields are private to `engine`. A sink cannot fabricate or edit coordinates. +- `PartitionKey` has no public constructor; `partition_key()` is its only + source. +- `StackedQuantities::push_group` still takes `DesignCoords` from its caller. + That is safe for the reason `2026-08-11` §3.5 gives — it is a rendering seam + over groups that are already separated, and a renderer cannot re-merge them — + and after this change the coordinates it receives are computed by + `DesignCoords::of` from a key the accumulator derived. + +Reason 2 (`BandKey::of` cannot serve predict) is removed by Stage 4: predict's +sweep is in the cell, so one derivation serves both verbs. Reason 3 is the +subject of §4. Reason 4 (unreachable) no longer holds: `simulate --draws FILE` +reaches the design path the day Stage 3 lands, and predict's sweep reaches it at +Stage 4. + +## 9. What changes for users + +| # | Command | Today | After | +| - | ------------------------------------------------------------------------------- | ------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 1 | `simulate --draws FILE --quantities-out d` | one band pooled over the file's rows | one row per design point with `sweep:` columns (one band per design point over replicates under `--replicates N`); `--draws-kind sample` restores the pooled band (§10 D1) | +| 2 | `simulate --draws FILE --draws-kind sample --scenario S` (S sets a file column) | refused (flag does not exist) | allowed as a counterfactual; shadowed columns in stderr and manifest | +| 3 | `simulate --draws FILE --scenario S` (S sets a file column) | refused | refused (unchanged; message names the design origin) | +| 4 | `batch run` sweep or design block × a scenario setting a swept parameter | runs; cells identical | refused (the gh#572 outcome) | +| 5 | `simulate --draws uniform | prior | posterior --scenario S` (S sets a drawn column) | +| 6 | Any banded quantities output | no statement of what the band is over | manifest `summary` object (additive) | +| 7 | `fit predict --sweep …` | — | byte-identical outputs (Stage 4 is required to be) | +| 8 | `run.json` provenance tag for a draw or sweep value, where written | `sweep_point` | `sample` or `design_point` | + +Row 1 is loud rather than silent: a per-point table has a different header and +`n_rows × n_points` rows, so it cannot be mistaken for a band. That is the +property that makes design the safe default (§10 D1). + +**Run identity does not re-key.** Identity hashes resolved parameter values and +the process seed (`resolve.rs:202-233`). §5.3 shows every current cell keeps its +seed; §5.4 shows every current cell keeps its resolved values. The provenance +tag in row 8 is recorded, not hashed, for `sim` leaves (`run.json.inputs` is +display-only, commit `2e141e0a`); for fit and profile leaves, whether +`parameters_provenance` (`run_meta.rs:644`) enters any hash is _not verified_ — +Stage 5 carries the test that settles it. + +**IR and goldens are unaffected.** All changes are in `rust/crates/cli`. No +`ir/VERSION` bump, no `ocaml/` change, no golden regeneration. + +## 10. Decisions for the maintainer + +**D1 — What does an explicit `--draws FILE` default to, and how is it +declared?** + +- (a) Default design; `--draws-kind sample` asserts a measure + (`Measure:: + Asserted`). Collision behaviour for undeclared files is + unchanged from today. Breaks one documented recipe's output shape: "Posterior + predictive from a draws file" (`docs/camdl-run-spec.md:1952-1954`) would emit + per-draw rows under `--quantities-out` until `--draws-kind sample` is added. + Loud, not silent. +- (b) Default sample; `--draws-kind design` for grids. No recipe breaks, but a + hand-authored grid passed without the flag is banded — the silent wrong answer + reason 3 names. +- (c) No default: refuse `--draws FILE` without `--draws-kind` whenever the kind + is load-bearing (`--quantities-out`, or a scenario footprint touching a file + column). Most honest; adds a required flag to existing invocations in those + cases. +- In-file declaration (a directive line in the TSV header) is rejected: the + reader treats line 1 as the header (`main.rs:4384-4396`), so a directive would + break every older camdl and every tool reading these files, and `--draws-out` + round-trips would need a format change. + +Recommendation: (a). A per-point table in place of a band is a visible change a +user will ask about; a band over a grid is not. `--draws-out` should print the +`--draws-kind sample` hint when it writes a generated sample, so round-trips +stay one flag away. Confidence: **need you** — it changes a documented recipe. + +**D2 — Is `--draws uniform` a sample or a design by default?** + +- (a) Sample with `Measure::UniformBounds`: bands as today; the manifest says + `uniform_bounds`, never `posterior`. `--draws-kind design` gives per-point + rows. +- (b) Design: per-point rows by default, which is what sensitivity exploration + usually wants, at the cost of changing today's output and of large tables + (`-n 500` × time rows per quantity). + +Recommendation: (a). The law is real (§3), the band is a correct Monte Carlo +estimate of its push-forward, and the new `summary.measure` field stops it being +read as a credible interval. Confidence: **leaning**. + +**D3 — Move `fit predict`'s sweep onto the grid (Stage 4) now, or leave predict +folding its sweep into draw rows?** + +- (a) Move it: predict builds one plan (sweep design × posterior sample), runs + all scenarios and sweep points in one `run_job`, and deletes its private guard + and the fresh-sink-per-sweep-point loop (`predict.rs:2193-2330`). One + derivation of the partition key serves both verbs. +- (b) Leave it: `PartitionKey` is computed from the cell in `simulate` and from + a loop variable in predict — the §2.2 asymmetry persists, and so does the + second guard. + +Recommendation: (a), gated on the byte-identity test in Stage 4. `2026-08-11` §4 +declined this because only the scenario half of the key was reachable; Stage 3 +makes the design half reachable in `simulate`, which removes that objection. The +conditioned-read routing (`conditioned_here`, `predict.rs:934-943`) becomes a +function of the cell's `PartitionKey` (conditioned iff scenario is the +conditioned arm and design is unit) — the same three conditions it checks today. +Confidence: **leaning**. + +**D4 — Should design points share common random numbers?** + +- (a) Keep today's seeds (§5.3): `batch run` and `fit predict` share seeds + across design points; a design read from a file in `simulate` does not. + Byte-neutral. +- (b) Key the seed on `(sample index, rep)` only, so every design axis shares + seeds across its points, as `batch run` and predict already do. Makes paired + comparisons across design points low-variance everywhere; changes the + trajectories (and therefore the run ids) of every `simulate --draws FILE` run + with more than one row. + +Recommendation: (a) in this proposal; file (b) as its own issue, because it is a +deliberate re-key with its own justification and should not ride in on a type +change. Confidence: **solid** on not bundling it; **need you** on whether (b) is +wanted at all. + +**D5 — What column prefix does a non-sweep design coordinate use?** + +- (a) `sweep:` for every design origin: no change to predict headers or + the manifest `sweep` object; the prefix means "design coordinate" in the docs. +- (b) `design:` everywhere: the name is accurate for a file or a + generated design, and pre-1.0 renames are cheap (`VERSIONING.md`), but it + changes `fit predict`'s headers and the manifest key, which + `fit_predict_sweep.rs:191` pins. + +Recommendation: (a). Confidence: **leaning**. + +## 11. Staged implementation + +Each stage lands alone with a green `make test`. Stages 0-2 and 4 are +byte-neutral; 3 and 5 carry the user-visible changes in §9. + +**Stage 0 — one constructor for `CellSpec`.** `CasSink::predict_cells` +(`batch.rs:1265-1300`) calls `plan_grid` on the job instead of re-looping; the +two test fixtures go through a `#[cfg(test)]` constructor. _Test:_ for a sweep × +two scenarios × three seeds manifest, the dry-run hit/miss classification and +every predicted `run_id` equal those of the real run (a new CLI test running +`--dry-run`, then the run, then `--dry-run` again, asserting all-miss then +all-hit with identical leaf paths). + +**Stage 1 — `ParamPlan` and `CellCoords`.** Replace `ParamSource`; split +`point_overrides`; add `seed_index`; migrate the §5.6 consumers mechanically, +keeping every behaviour. _Tests:_ `tests/determinism_pin.rs` unchanged and +green; a new unit test in `engine` that plans the four current job shapes +(point, generated draws, draws file, sweep with explicit seeds) and asserts each +cell's `process_seed` equals `process_seed_for` evaluated with the old +`point_idx` — the old formula kept in the test as the oracle; an A/B over +`simulate --draws uniform -n 5 --scenario a,b` and a `batch run` sweep, diffing +every artifact including `run.json`. + +**Stage 2 — the collision guard keys on the plan.** §6.1 replaces +`check_explicit_draws_scenario_collision`; the in-flight gh#572 guard is +superseded or rebased onto it. Predict's guard stays until Stage 4. _Tests:_ the +gh#572 red test (a `batch run` sweep and a `[design]` block each colliding with +a scenario → non-zero exit naming both sources); `--draws FILE` collision still +refused; `--draws uniform --scenario S` with S pinning a drawn column → exit 0 +and one stderr line naming the shadowed column. Mutation check: make the guard +treat the design axis as a sample axis and confirm the batch test goes red. + +**Stage 3 — design partitions and the `summary` block in `simulate`.** +`--draws-kind`, `Measure` construction at the `simulate` boundary, +`ParamPlan::mode`, `by_partition`, `DesignCoords::of`, the manifest `summary`. +_Tests:_ `--draws FILE` (three rows) `--quantities-out` → a scalar quantity file +with three rows, `sweep:` columns, point-mode header; the same with +`--draws-kind sample` → one band row with `n_draws = 3`; `--draws uniform -n 5` +→ `summary.measure == "uniform_bounds"`, `over == ["sample"]`; `--replicates 4` +with no draws → `measure == "process"`. Mutation check: key `by_partition` on +the scenario alone and confirm the three-row assertion fails on the row count, +not the header. + +**Stage 4 — `fit predict` on the grid (if D3 is (a)).** One plan, one `run_job` +for all scenarios and sweep points; `merge_cell` keys on `PartitionKey`; +predict's guard deleted; `conditioned_here` reads the key. _Tests:_ +byte-identity A/B of every predict artifact (predictive TSVs, quantities TSVs, +all manifests) for `--sweep k=… --scenario a,b` with and without a conditioned +fit, same seed; the existing header pin (`fit_predict_sweep.rs:191`); the +existing predict sweep-collision test, now passing through the shared guard. + +**Stage 5 — provenance.** `ValueSource::{Sample, DesignPoint}`; the per-cell +manifest records `requested` alongside effective values for design coordinates +(§12). _Test:_ one fit leaf and one `sim` leaf before and after — `run_id` +unchanged, the provenance tag changed — which settles the unverified question in +§9. + +## 12. Requested and effective values for a future scale × sweep composition + +Today a scenario that scales a swept parameter is refused (§6.1), so a design +coordinate's requested value always equals the value the cell ran. If a later +proposal admits composition — sweep `mu ∈ {1, 2, 3}` under +`scale = { mu = 2.0 }` running `{2, 4, 6}` — the representation is already +decided here: + +- **Identity hashes only effective values.** It does already (§3). Two cells + whose effective values coincide under different requested coordinates share a + `run_id` and are one computation in the store; that is correct. +- **The partition key is the requested coordinate.** It is the index of the axis + the user chose (`DesignCoord::Point { idx }`), not a value. +- **A column denotes one thing.** `sweep:mu` keeps meaning the requested grid + value; the effective value, where it differs, is reported under a separately + named column and in the manifest's per-cell `requested`/`effective` pair + (Stage 5 writes both; they are equal until composition exists). The denotation + condition of `2026-08-11` §1.1 is then satisfied by naming, not by forbidding. + +Admitting composition is out of scope; this section fixes only where its +metadata would go, so Stage 5 records the right shape once. From 1f66246e26f8cfede057548c37656713384d9761 Mon Sep 17 00:00:00 2001 From: Vince Buffalo Date: Tue, 29 Sep 2026 10:50:18 -0700 Subject: [PATCH 2/5] docs(proposal): record the coordinate-kind decisions and workflows Every open call is decided, so the proposal ships with no open questions. The ambiguous --draws FILE splits by axis into --draws-file (a sample) and --design (design points), and a bare --draws is refused naming both; simulate gains --sweep on the CLI grammar fit predict already uses; --draws uniform is a sample under a uniform-bounds measure; fit predict's sweep moves onto the grid behind a byte-identity gate; seeds and the sweep: prefix stay as they are. DesignAxis becomes a sum type with Unit as its own variant, and the collision diagnostic names the design's origin. New sections set out how the user drives each axis and walk eleven workflows end to end on one SIR model. The staged plan places the flag split with the guard rework, now that the gh#572 guard is on main keyed on ParamSource. Every file:line is re-checked against ebe845a0. Relates-to: gh#562, gh#572, gh#575, gh#948 --- ...-09-27-coordinate-kind-design-vs-sample.md | 1470 ++++++++++------- 1 file changed, 894 insertions(+), 576 deletions(-) diff --git a/docs/dev/proposals/2026-09-27-coordinate-kind-design-vs-sample.md b/docs/dev/proposals/2026-09-27-coordinate-kind-design-vs-sample.md index 20a9fbcd..44f34e31 100644 --- a/docs/dev/proposals/2026-09-27-coordinate-kind-design-vs-sample.md +++ b/docs/dev/proposals/2026-09-27-coordinate-kind-design-vs-sample.md @@ -1,18 +1,18 @@ # A parameter point is a design coordinate or a sample, and the cell says which - Date: 2026-09-27 -- Status: draft — five calls are open for the maintainer (§10); everything else - is decided inline -- Relates-to: gh#562, gh#572, gh#575, +- Status: accepted — every call is recorded in §13; ready to implement +- Relates-to: gh#562, gh#572, gh#575, gh#948, `2026-06-27-sealed-fit-packets-handles-and-override-algebra.md` (§4), `2026-08-11-scenario-banding-in-simulate.md` (implemented) - Builds on: an untracked architecture-review note on gh#562 (2026-08-11). Every - claim this proposal uses from it is restated in §2; the note is not needed to + claim this proposal uses from it is restated in §4; the note is not needed to read this document. -All code references are against `origin/main` at `2e141e0a`. A reference marked +All code references are against `origin/main` at `ebe845a0`. A reference marked _verified_ was read at that line; _inferred_ means reasoned from the code but -not executed. +not executed. Output shown under **after** is the format this proposal +specifies; it does not exist yet. ## Summary @@ -23,20 +23,47 @@ which kind an axis is: whether a scenario may override it, how its cells are labelled, and whether a quantile may be taken across it. Today none of the three can read that property off the cell, because the cell does not carry it. Each decision instead consults `ParamSource` — which CLI flag produced the parameter -points — and the flag is the wrong property: it classifies a hand-authored grid -passed via `--draws` as a sample, and classifies an iid uniform design in -`batch run` as a partition while classifying the same draw in `simulate` as a -sample. +points — and the flag is the wrong property. `--draws grid.tsv` classifies a +hand-authored grid as a sample; an iid uniform design is a partition in +`batch run` and a sample in `simulate`. -This proposal replaces `ParamSource` with a `ParamPlan` that holds two typed -axes — a **design axis** and a **sample axis** — and splits the cell's single +This proposal replaces `ParamSource` with a `ParamPlan` holding two typed axes — +a **design axis** and a **sample axis** — and splits the cell's single `point_overrides` map into a design coordinate and a sample coordinate. Collision policy, leaf labels and provenance, and banding then key off one -property that the cell carries, set by one constructor. `fit predict`'s sweep -moves onto the grid's design axis so both verbs put their partition coordinates -in the cell. Nothing re-keys run identity; the IR and goldens are untouched. - -## 1. Terms +property the cell carries, set by one constructor. On the command line the +ambiguous `--draws FILE` splits by axis into `--draws-file FILE` (a sample) and +`--design FILE` (design points); `simulate` gains `--sweep`; `fit predict`'s +sweep moves onto the grid, so both verbs put their partition coordinates in the +cell. Nothing re-keys run identity; the IR and goldens are untouched. + +## 1. How the user works with each axis + +Every simulation grid is a product of four axes. The user controls each with a +distinct flag or manifest key, and each plays one role in the output. + +| Axis | `simulate` (after) | `batch run` manifest | `fit predict` | Output role | +| ------------- | ---------------------------------------------------------- | ---------------------------- | ----------------------------- | ------------------------------------------------ | +| **Scenario** | `--scenario a,b` | `[[scenario]]` | `--scenario a,b` (+ `fitted`) | group: `scenario` column | +| **Design** | `--sweep beta=…` or `--design FILE` | `[sweep]` or `[design.NAME]` | `--sweep beta=…` | group: `sweep:` columns | +| **Sample** | `--draws posterior\|prior\|uniform` or `--draws-file FILE` | none | always the fit's posterior | pooled into the band; manifest names the measure | +| **Replicate** | `--replicates N` or `--seeds a,b,…` | `seeds = {…}` | one per draw | pooled into the band | + +The rule the output follows: **scenario and design make groups (rows); sample +and replicate are pooled into the band within each group.** A quantity file has +one row (or one block of time rows) per group, and its quantile columns +summarize the sample and replicate cells inside that group only. + +What stays the same: scenarios, replicates, seeds, +`--draws +posterior|prior|uniform`, `batch run` manifests, and `fit predict`'s +output bytes. What changes: a bare `--draws FILE` is refused with a message +naming its two replacements; `simulate --sweep` and `simulate --design` are new +and emit `sweep:` columns; a scenario that touches a sampled column is +recorded in the manifest; every banded manifest entry says what its band is over +(§9.2). + +## 2. Terms A **cell** is one simulation: one scenario, one parameter vector, one stochastic replicate (`engine::CellSpec`, `engine.rs:70-88`, verified). @@ -57,8 +84,8 @@ space-filling design. Design coordinates form a partition axis. A **sample** is a parameter point drawn from a stated probability measure over parameters — the posterior of a fit, a prior, a uniform law over declared bounds. Samples form a band axis. The measure is what makes a quantile over them -mean something: it is an estimate of a quantile of the push-forward of that -measure through the model. +mean something: it estimates a quantile of the push-forward of that measure +through the model. A **marginal reduction** summarizes a set of cells treating them as exchangeable draws from one distribution — a quantile, a mean. A **paired reduction** @@ -66,34 +93,80 @@ combines cells from _different_ values of a partition axis that share a band coordinate — draw _i_ of the baseline arm with draw _i_ of the intervention arm — to construct a new random variable before any marginal summary. -## 2. The problem, restated from the review note +A scenario **shadows** a sampled column when it `set`s that parameter: every +sample then runs at the scenario's value, so that column's variation is removed +from the band. A scenario that `scale`s a sampled column **rescales** it: the +variation is kept and multiplied. + +## 3. The running example + +Every workflow in §5 uses one SIR model (compartments S, I, R; S susceptible, I +infectious, R recovered): + +``` +time_unit = 'days +compartments { S, I, R } +parameters { + beta : rate in [0.05, 1.0] ~ log_normal(mu = -1.2, sigma = 0.5) + gamma : rate in [0.02, 0.5] ~ log_normal(mu = -2.3, sigma = 0.3) +} +init { S = 990 I = 10 R = 0 } +transitions { + infect : S --> I @ beta * S * I / (S + I + R) + recover : I --> R @ gamma * I +} +simulate { from = 0 'days to = 100 'days } +scenarios { + distancing { scale = { beta = 0.6 } } + pinned { set = { beta = 0.3 } } +} +quantities { + peak_I = max(I) + final_R = final(R) +} +``` -This section restates the parts of the gh#562 review note that this proposal -builds on, so the proposal stands alone. +Measured on `main` (`ebe845a0`), ODE backend, `dt = 0.1`, `gamma = 0.1`, N = +1000, I₀ = 10, days 0–100: `peak_I` is 70, 304 and 536 at `beta` = 0.15, 0.30 +and 0.60. Every other number in §5 is written `…` rather than invented. -### 2.1 One field carries two meanings, and the cell does not say which +## 4. The problem -`CellSpec.point_overrides` (`engine.rs:81`, verified) is the parameter point for -the cell. When the job's `ParamSource` is `Sweep` (`sim_job.rs:175`), each point -is a different model — a partition coordinate. When it is `Draws` -(`sim_job.rs:178-191`), each row samples one model — a band coordinate. The same +### 4.1 One field carries two meanings, and the cell does not say which + +`CellSpec.point_overrides` (`engine.rs:81`, verified) is the cell's parameter +point. When the job's `ParamSource` is `Sweep` (`sim_job.rs:175`), each point is +a different model — a partition coordinate. When it is `Draws` +(`sim_job.rs:178-192`), each row samples one model — a band coordinate. The same loop in `plan_grid` (`engine.rs:168-191`, verified) stamps both into the same -field. No function taking only a `&CellSpec` can compute a correct partition -key. The note observed that this was contained only because sweeps existed -solely in `batch run`, which emits no quantities; that is still true on `main`. +field. No function taking only a `&CellSpec` can compute a correct grouping key. -### 2.2 `fit predict` keeps its sweep coordinate outside the cell +The user-visible consequence, measured on `main`: a three-row file of `beta` +values 0.15, 0.30, 0.60 passed as `--draws grid.tsv --quantities-out q` writes -`fit predict` does not use the grid's sweep axis. It expands `--sweep` itself -(`predict.rs:1589`, verified), then for each sweep point overwrites the swept -parameters in every posterior draw row (`predict.rs:2234-2244`) and runs one -`ParamSource::Draws` job per scenario (`predict.rs:2258-2262`, -`scenarios: vec![sref.clone()]`). The sweep value lives in the loop variable -`sweep_pt` (`predict.rs:2193`) and reaches the renderer directly -(`predict.rs:2301-2304`). Every predict cell therefore looks like a posterior -draw; its partition coordinate is not in the cell. +``` +n_draws q05 q25 q50 q75 q95 +3 93.4 187 304 420 512.8 +``` + +for `peak_I` — a "band" over three conditions the user meant to compare. Its +median is the middle condition's value and its tails are interpolations between +conditions; it describes no distribution anyone stated. -### 2.3 The rejected design, and the four reasons +### 4.2 `fit predict` keeps its sweep coordinate outside the cell + +`fit predict` does not use the grid's sweep axis. It expands `--sweep` itself +(`predict.rs:1590`, verified), then for each sweep point overwrites the swept +parameters in every posterior draw row (`predict.rs:2235-2237`) and runs one +`ParamSource::Draws` job per scenario (`predict.rs:2254`, `:2262`). The sweep +value lives in the loop variable of `for sweep_pt in &sweep_points` +(`predict.rs:2188`) and reaches the renderer directly (`predict.rs:2297-2300`). +Every predict cell looks like a posterior draw; its partition coordinate is not +in the cell, and its collision check must run separately +(`predict.rs:1623-1649`, verified, whose comment says so: "predict folds the +sweep into the draw rows, which the engine sees as generated draws"). + +### 4.3 The rejected design, and the four reasons The gh#562 work designed and rejected a general coordinate type: @@ -110,192 +183,470 @@ It was rejected on four grounds (recorded in `2026-08-11` §4): `BandSet::push(key, …)` takes the key from its caller, so a caller passing one key for every cell pools exactly as before. The shipped alternative — an - accumulator whose only input is a whole cell, `push_cell(&CellResult)`, - deriving its own key (`main.rs:2452-2456`, verified) — gives the caller no - key to pass. -2. **`BandKey::of(&CellSpec)` cannot serve `fit predict`** (§2.2), so a second + accumulator whose only input is a whole cell, deriving its own key + (`main.rs:2464`, verified) — gives the caller no key to pass. +2. **`BandKey::of(&CellSpec)` cannot serve `fit predict`** (§4.2), so a second public constructor would be needed. 3. **`PointKind` names the wrong property.** It records which CLI flag produced the points, not whether they carry a measure over which a quantile means - anything. A hand-authored grid passed via `--draws` would classify as a band; - the same grid via a sweep as a partition. `--draws uniform` bands today, - though no belief stands behind it. + anything. A hand-authored grid passed via `--draws` classifies as a band + (§4.1 shows the result); the same grid via a sweep as a partition. 4. **It was unreachable:** `BandKey::of` would have returned an empty sweep at every live call site. -### 2.4 The collision policy is being keyed on the same wrong property +### 4.4 The landed gh#572 guard keys on the same proxy + +gh#572 (a scenario silently overriding a swept or file-supplied parameter) is +fixed on `main`. `engine::check_scenario_coordinate_collision` +(`engine.rs:364-390`, verified) runs from `run_job` (`engine.rs:203`), from +`batch::batch_job` (`batch.rs:605`) so dry-run and status refuse too, and from +`simulate --dry-run` (`main.rs:1799`). Its wording comes from one formatter, +`params_resolver::scenario_coordinate_collision` (`params_resolver.rs:842-940`), +which `fit predict` also calls (`predict.rs:1637`). The policy is the +maintainer's: a scenario touching a _user-authored_ coordinate — a sweep, a +design block, a `--draws ` — is refused, `set` and `scale` alike; +generated draws (`--draws posterior|prior|uniform`) are not checked. + +The guard decides which is which by matching `ParamSource` +(`engine.rs:367-375`): `Sweep` and `Draws { explicit_file: Some(_) }` are +checked, everything else passes. So the correct policy is keyed on the proxy +reason 3 rejects, and it inherits the proxy's blind spots: a `[design.NAME]` +block is reported as "the sweep" (`params_resolver.rs:881`, because the design +path builds a `Sweep`, `batch.rs:1839`), and predict's sweep needs its own call +because the engine cannot see it. This proposal keeps the policy and replaces +the key (§8). + +## 5. Workflows, end to end + +Each workflow gives the modelling question, the commands, the output headers +with example rows, the manifest `summary` object (§9.2), and what each relevant +collision does. File paths are relative to the `--quantities-out` directory `q/` +unless stated. Series quantities are omitted for brevity; `peak_I` and `final_R` +are scalars, so each file has one row per group. + +### W1 — Sensitivity sweep: how does the peak depend on `beta`? + +**Today** there is no `simulate --sweep`; the nearest spelling is a draws file, +which pools (§4.1). `batch run` sweeps but emits no quantities. + +**After**, three spellings, one result: + +``` +camdl simulate sir.camdl --params base.toml --sweep beta=0.15,0.3,0.6 \ + --backend ode --dt 0.1 --quantities-out q + +camdl simulate sir.camdl --params base.toml --design grid.tsv \ + --backend ode --dt 0.1 --quantities-out q # grid.tsv: one column `beta` + +# batch.toml +[sweep] +beta = [0.15, 0.3, 0.6] +``` + +`q/quantities/peak_I.tsv` (point mode — one realization per group): + +``` +sweep:beta value +0.15 70 +0.3 304 +0.6 536 +``` + +Manifest entry for `peak_I`: `"sweep": {"beta": 0.15}` per group as today in +`fit predict`, and no `summary` object, because point mode reduces nothing. + +**With `--replicates 20 --backend chain_binomial`**, each design point gets its +own band over process noise: + +``` +sweep:beta n_draws q05 q25 q50 q75 q95 +0.15 20 … … … … … +0.3 20 … … … … … +0.6 20 … … … … … +``` + +`"summary": {"over": ["replicate"], "measure": "process", "n_samples": 0, +"replicates_per_sample": 20, "scenario_touches": {}}`. + +**Collisions.** `--scenario pinned` (`set beta`) or `--scenario distancing` +(`scale beta`) touches the swept parameter and is refused before any cell runs +(W10 shows the message). `--sweep gamma=0.05,0.1,0.2 --scenario distancing` +touches distinct parameters and runs: six groups, `scenario sweep:gamma +value`. + +### W2 — Space-filling design: where in (`beta`, `gamma`) space is the epidemic large? + +**Today and after**, the batch spelling is unchanged: + +``` +[design.wide] +method = "lhs" +n = 64 +parameters.beta = { range = { min = 0.05, max = 1.0 } } +parameters.gamma = { range = { min = 0.02, max = 0.5 } } +``` + +It writes `designs/wide/parameter_points.tsv` and one store leaf per point, as +today; `batch run` emits no quantities. **After**, the same points can be +simulated with quantities by passing the generated table as a design: + +``` +camdl simulate sir.camdl --design out/designs/wide/parameter_points.tsv \ + --backend ode --quantities-out q +``` + +``` +sweep:beta sweep:gamma value +0.43… 0.11… … +… +``` + +(64 rows.) `parameter_points.tsv` carries a `point_id` column +(`batch.rs:1853-1859`, verified); `--design` treats a column named `point_id` as +a row label and not a parameter, and refuses any other non-parameter column. +Collisions: a scenario touching `beta` or `gamma` is refused, naming "the design +`wide`" from `batch run` and "the design file `parameter_points.tsv`" from +`simulate`. + +### W3 — Prior predictive and uniform exploration: what does the model predict before seeing data? + +**Today and after**, the command is unchanged: + +``` +camdl simulate sir.camdl --draws prior -n 500 --backend ode --quantities-out q +camdl simulate sir.camdl --draws uniform -n 500 --backend ode --quantities-out q +``` + +`q/quantities/peak_I.tsv` is unchanged: + +``` +n_draws q05 q25 q50 q75 q95 +500 … … … … … +``` + +The manifest gains, for `--draws prior`: + +```json +"summary": { "over": ["sample"], "measure": "prior", "n_samples": 500, + "replicates_per_sample": 1, "scenario_touches": {} } +``` + +and for `--draws uniform`, `"measure": "uniform_bounds"` — quantiles under the +uniform law on the declared bounds, which no one should caption as a credible +interval. + +**Per-point rows instead of a band.** Export the sample, then read it back as a +design: + +``` +camdl simulate sir.camdl --draws uniform -n 500 --draws-out u.tsv --backend ode +camdl simulate sir.camdl --design u.tsv --backend ode --quantities-out q +``` + +giving `sweep:beta sweep:gamma value`, 500 rows — the table for plotting +`peak_I` against each parameter. + +**Collisions.** `--scenario pinned` over the prior shadows `beta`: it runs, with +one stderr line +(`scenario 'pinned' sets sampled parameter beta; its prior +variation is removed from this arm`) +and `"scenario_touches": {"beta": "set"}` in that arm's `summary`. +`--scenario distancing` rescales `beta`: +`"scenario_touches": {"beta": "scale"}`, no stderr line. + +### W4 — Posterior predictive under interventions: what does distancing do, given what the fit learned? -A scenario resolves at tier 4 of the parameter resolver and a draw row or sweep -point at tier 3.5 (`params_resolver.rs:48-52`, `:880-903`, `:905-946`, -verified), so a scenario that sets or scales a parameter the point also sets -silently wins. The run-spec audit on gh#572 reproduced three sweep leaves, and -three `--draws uniform` leaves under `--scenario baseline`, each set -byte-identical. +**Today and after**: -The existing guard, `check_explicit_draws_scenario_collision` -(`engine.rs:358-405`, verified), fires only for -`ParamSource::Draws { explicit_file: Some(_) }`. `fit predict` has its own guard -for its own sweep (`predict.rs:1628-1653`, verified). `batch run` has none. The -maintainer's policy for gh#572 is: a scenario colliding with a _user-authored_ -coordinate (a sweep, an explicit draws file) is refused; one colliding with a -_generated_ draw (`prior`, `uniform`, `posterior`) is allowed. That fix is in -progress keyed on `ParamSource` — the property reason 3 identifies as the wrong -one. This proposal supplies the property it should key on. (The in-progress -branch was not inspected; its keying is as described in the task that -commissioned this proposal.) +``` +camdl simulate sir.camdl --fit results/fits/sir-8a3f12b4 --draws posterior \ + --scenario baseline,distancing --backend ode --quantities-out q +``` + +`q/quantities/peak_I.tsv` is unchanged: + +``` +scenario n_draws q05 q25 q50 q75 q95 +baseline 200 … … … … … +distancing 200 … … … … … +``` + +Each arm's manifest entry gains a `summary`; for `distancing`: + +```json +"summary": { "over": ["sample"], "measure": "posterior", "n_samples": 200, + "replicates_per_sample": 1, "scenario_touches": {"beta": "scale"} } +``` + +`--scenario pinned` shadows `beta` in its arm (`{"beta": "set"}`, one stderr +line); `gamma` still varies across the 200 draws, so `over` stays `["sample"]`. +The draws are paired across arms — draw _i_ uses one parameter vector and one +process seed in both (§6.3). + +### W5 — Exporting a posterior and reusing it + +**Today**: + +``` +camdl simulate sir.camdl --fit results/fits/sir-8a3f12b4 --draws posterior \ + -n 200 --draws-out post.tsv +camdl simulate sir.camdl --draws post.tsv --scenario distancing ... +``` + +The second command is refused today, because `distancing` scales the file's +`beta` column and a draws file is a user-authored coordinate (§4.4). + +**After**, `--draws-out` ends with a round-trip hint: + +``` +draws.tsv: wrote 200 draws to post.tsv (reuse as a sample with --draws-file post.tsv) +``` + +and + +``` +camdl simulate sir.camdl --draws-file post.tsv --scenario distancing \ + --backend ode --quantities-out q +``` + +runs: the file is a sample (`Measure::Asserted`), so the scenario is a +counterfactual over it. Output as in W4 with +`"measure": "asserted", "source": "post.tsv"` in the `summary`. The same file +passed as `--design post.tsv --scenario distancing` is refused, naming "the +design file `post.tsv`". + +### W6 — `fit predict` over a sweep, the posterior, and scenarios + +**Today and after**, the command and its output bytes are identical (Stage 4 is +gated on byte identity): + +``` +camdl fit predict @sir-fit --sweep gamma=0.08,0.12 --scenario distancing +``` + +`/quantities/peak_I.tsv`: + +``` +scenario sweep:gamma n_draws rhat ess q05 q25 q50 q75 q95 +fitted 0.08 200 … … … … … … … +fitted 0.12 200 … … … … … … … +distancing 0.08 200 … … … … … … … +distancing 0.12 200 … … … … … … … +``` + +What changes is internal: the sweep is the job's design axis and the posterior +its sample axis, so one `run_job` runs every (scenario, sweep point, draw), and +the grouping key comes from the cell. The manifest gains `summary` with +`"measure": "posterior"`. `--sweep beta=… --scenario distancing` is refused by +the shared guard, naming "the sweep" (today predict's own call does this). + +### W7 — Contrasts: how many infections does an intervention avert? -## 3. What the code shows, beyond the note +A contrast is a paired reduction: `fit predict` replays both arms per forkable +posterior draw under one seed, `derive_chain_seed(seed, draw_pos)` +(`contrasts.rs:447`, verified), differences them, then bands the differences +into `contrasts/.tsv` with header `q05 q25 q50 q75 q95 mean n_used` for a +scalar (`contrasts.rs:1240`). A contrast needs an arm that toggles an +intervention; a parameter-only scenario such as `distancing` is skipped with a +note (`contrasts.rs:32`, gh#327). So W7 adds an intervention `closure` and a +scenario `close { enable = [closure] }`, and declares +`averted = fitted.quantities.final_R - close.quantities.final_R`. -Five facts found while reading the code sharpen or correct the note. +**Today and after**, `camdl fit predict @sir-fit` writes +`contrasts/averted.tsv`: -**`--draws uniform` is an iid sample, not a space-filling design.** +``` +q05 q25 q50 q75 q95 mean n_used +… … … … … … 200 +``` + +Under the new coordinates the pairing coordinate has a name — the cell's sample +coordinate — and the rule is: a paired reduction across a partition axis is +valid iff both sides share one sample axis (§6.3). Within a job this always +holds across scenarios. + +**With a design axis present**, pairing would be within each design point +(scenario arms matched on sample coordinate, one contrast band per +`sweep:` value). That is not delivered here, because contrasts fork each +draw from its _smoothed_ state, inferred under the fitted parameters; a sweep +point replaces a parameter, so no smoothed state exists for it — the same reason +`conditioned_here` refuses a conditioned read for any swept cell +(`predict.rs:934-943`, verified). Contrasts therefore stay sweep-agnostic, as +today (`emit_contrasts` receives no sweep, `predict.rs:2731-2739`, verified): +emitted once, for the un-swept model. A free-forward contrast that pairs within +design points is a separate feature to file as its own issue. + +### W8 — Process noise only: how variable is one parameter set's epidemic? + +**Today and after**: + +``` +camdl simulate sir.camdl --params base.toml --replicates 50 \ + --backend chain_binomial --quantities-out q +``` + +``` +n_draws q05 q25 q50 q75 q95 +50 … … … … … +``` + +`"summary": {"over": ["replicate"], "measure": "process", "n_samples": 0, +"replicates_per_sample": 50, "scenario_touches": {}}`. +`--seeds 1,2,3` is the same with explicit seeds. Under `--backend ode` +replicates are identical (the ODE solver draws no randomness), so `over` omits +`replicate` and `measure` is absent: the band has zero width and the manifest +says it reduces nothing. Scenarios make groups as usual; nothing collides, +because there are no parameter coordinates. + +### W9 — A hand-authored grid passed the old way + +**Today**, `camdl simulate sir.camdl --draws grid.tsv --quantities-out q` writes +the pooled band of §4.1. + +**After**, it is refused before any work: + +``` +error: `--draws grid.tsv` no longer accepts a file: say which axis its rows are. + --design grid.tsv the rows are conditions to compare (a grid, a + hand-picked set): one output row per point, reported + in `sweep:` columns + --draws-file grid.tsv the rows are a sample from a distribution you stand + behind (e.g. exported posterior draws): pooled into + one quantile band +`--draws` takes only `posterior`, `prior` or `uniform`. +``` + +### W10 — A `batch run` sweep that a scenario would override + +**Today** (landed with gh#572), a manifest sweeping `beta` under `pinned` is +refused by `batch run`, `batch run --dry-run` and `batch status`, worded by +`params_resolver.rs:842-940`: + +``` +error: parameter `beta` is controlled by both the sweep and scenario `pinned` + sweep: beta = [0.15, 0.3, 0.6] + 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. +``` + +(Layout inferred from the formatter; the column width is +`max(len(label), len("scenario:")) + 2`.) Under `distancing` the middle lines +read `scale beta × 0.6` and "would rescale every sweep point". **After**, the +same message names the design's origin: "the sweep" for `[sweep]` and `--sweep`, +"the design `wide`" for `[design.wide]`, "the design file `grid.tsv`" for +`--design grid.tsv`. The draws-file wording is deleted: a sample never collides +(W3–W5). + +### W11 — `--design-from`: synthetic datasets on a fit's observation design + +`--design-from fit.toml` writes a dataset — one file per observation stream on +the fit's own observed rows — at each parameter vector; with `--draws` it writes +one `ds_NN/` per row (`args/mod.rs:470-489`; `simulate_on_bound_design`, +`main.rs:3248`). It bypasses the engine, and it ignores `--scenario` entirely +(gh#948, a separate defect). + +**After**, it accepts every parameter source — +`--draws +posterior|prior|uniform`, `--draws-file`, `--design`, `--sweep` — and +still writes one dataset per parameter point, because it never reduces. The axis +decides two things only: the directory label (`ds_NN` over sample index for a +sample; `ds_NN` with the design coordinates recorded for a design) and a +`design_from.json` beside the datasets recording the kind and measure. That +record matters downstream: a simulation-based calibration pools rank statistics +across datasets and is valid only when the truths were a sample from the prior +(`--draws prior`); a recovery study at chosen truths (`--design truths.tsv`) +must not be pooled that way. A former `--draws truths.tsv` user gets the W9 +refusal and chooses. Once gh#948 routes `--scenario` through, the §8 guard +applies to `--design-from` unchanged. + +## 6. The rule: design coordinates condition, samples are marginalized + +### 6.1 The kind is a declared reading, and only a sample needs a measure + +The kind is not a property of the numbers. An iid uniform draw can be read as a +sample of the uniform law (to band a quantity over it) or as a set of conditions +(to plot the quantity against the parameter). What differs is the _question_ — +condition on the point or marginalize over it. The asymmetry that makes this +safe: **any point set may be read as a design; only a point set with a measure +may be read as a sample.** A sweep grid has no measure, so it can never be a +sample. A generated draw has a measure by construction. A file has one only if +the user asserts it, which is what `--draws-file` means. + +| Source | Axis | Measure | +| ------------------------------------------- | ------ | ----------------------- | +| `--draws posterior`, `fit predict` | sample | the fit's posterior | +| `--draws prior` | sample | prior (IR or fit.toml) | +| `--draws uniform` | sample | uniform on declared box | +| `--draws-file FILE` | sample | asserted by the user | +| `--sweep`, `[sweep]`, `fit predict --sweep` | design | — | +| `--design FILE` | design | — | +| `[design.NAME]` | design | — | + +`--draws uniform` is an iid sample, not a space-filling design: `generate_uniform_draws` draws `lo + (hi − lo)·U` independently per parameter -per row (`main.rs:3852-3889`, verified); parameters without declared bounds are -held at their default. The run spec calls it "space-filling exploration for -model debugging, not a prior" (`docs/camdl-run-spec.md:1138-1140`). So it _does_ -carry a measure — the uniform law on the declared box — and a quantile over it -is a well-defined Monte Carlo estimate of the push-forward of that law. What it -lacks is a claim that the law is anyone's belief. Consequence: "carries a -measure" is necessary for a band but not sufficient to call the band a credible -interval, and the same point set can legitimately be read either way (§4.1). - -**The actual space-filling designs already run as sweeps.** `batch run`'s -`[design.NAME]` blocks (`method = "sobol" | "lhs" | "random"`, -`batch.rs:98-107`, verified) are routed through `ParamSource::Sweep` -(`batch.rs:1870-1900`, verified). An iid uniform design is therefore a partition -axis in `batch run` and a band axis in `simulate` — reason 3 in running code, -not only in principle. - -**No output says "credible" today.** The quantity renderer writes `n_draws` and -`q05 … q95` (`quantity_output.rs:266`), and the manifest carries `schema`, -`calendar`, and per-quantity shape fields (`quantity_output.rs:466-515`); none -of them names what the band is over. The user-visible defect is therefore an -_unlabelled_ band whose meaning depends on the source, not a mislabelled one. - -**Run identity already hashes effective values.** Since PR #941 the `params` -identity level hashes the values the resolver hands the engine -(`batch.rs:1117-1135`, `resolve.rs:227-233`, verified), and the leaf path label -names the point's parameters at the values the cell _ran_ (`batch.rs:1139-1146`, -verified); `run.json` records the same resolved values as `inputs` -(`resolve.rs:237-244`). So gh#572's complaint that identity is keyed on the -requested coordinate is fixed. What still reports the requested value is the -_design_ surface: the dry-run's `sweep override` listing, a design block's -`parameter_points.tsv`, and `sweep:` quantity columns. - -**Common random numbers across design points are accidental and uneven.** The -process seed is `mix_cell_seed(base, point_idx, rep)` (`engine.rs:52-66`, -`util.rs:35-37`, verified) unless seeds are explicit, in which case it is -`seeds[rep]`. `batch run` always passes explicit seeds (`batch.rs:588-589`), so -its sweep points share seeds per replicate. `fit predict` runs one job per sweep -point in which `point_idx` is the draw index, so its sweep points share seeds -per draw. A hand-authored grid in `simulate` mixes its row index into the seed, -so its points share nothing. The typed split in §5 makes this visible; §10 D4 -decides what to do about it. - -Two smaller findings. `ValueSource::SweepPoint` tags a posterior draw's value -`"sweep_point"` (`params_resolver.rs:185-189`, `:200`, `:893`, verified) — the -same conflation at the provenance layer. And the doc comments at -`quantity_output.rs:35-36` and `:476-479` still describe `DesignCoords::none`, -which the gh#562 work deleted. - -## 4. The rule: design coordinates condition, samples are marginalized - -### 4.1 The kind is a declared reading, and only a sample needs a measure - -The working hypothesis was that a point is either a design coordinate or a -sample from a stated measure. The code confirms the dichotomy but refines where -it comes from: the kind is not a property of the numbers. An iid uniform draw -can be read as a sample of the uniform law (to band a quantity over it) or as a -set of design conditions (to plot the quantity against the parameter). A -posterior draw can be read as a sample (the posterior predictive) or as a -condition (one trajectory per draw). What differs is the _question_ — condition -on the point or marginalize over it. - -The asymmetry that makes this safe: **any point set may be read as a design; -only a point set with a measure may be read as a sample.** A sweep grid has no -measure, so it can never be a sample. A generated draw has a measure by -construction. A user-authored file has one only if the user asserts it. The type -in §5 encodes exactly this: `SampleAxis` cannot be constructed without a -`Measure`, and `DesignAxis` needs nothing. - -Each generator has a default reading: - -| Source | Default | Measure (if sample) | May be re-read as | -| -------------------------------------- | ------- | ----------------------- | ------------------- | -| `--draws posterior`, `fit predict` | sample | posterior of the fit | design | -| `--draws prior` | sample | prior (IR or fit.toml) | design | -| `--draws uniform` | sample | uniform on declared box | design (§10 D2) | -| `--draws FILE` | design | — | sample, if asserted | -| `batch run [sweep]`, `predict --sweep` | design | — | never (no measure) | -| `batch run [design.NAME]` | design | — | not offered (§4.4) | - -### 4.2 What each kind implies for the three decisions - -| Decision | Design coordinate | Sample | -| --------------------------------------- | ---------------------------------------------------------- | ------------------------------------------------------------------ | -| A scenario sets or scales the parameter | Contradiction — refused, naming both sources | Counterfactual — allowed; the shadowed columns are recorded (§6.2) | -| Labels and provenance | Reported as a column (`sweep:`) and in the manifest | Not reported per row; the manifest names the measure | -| Marginal reduction (quantile, mean) | Never crosses it | Crosses it | -| Paired reduction | Crosses it, matched on the band coordinate | Supplies the matching coordinate | - -The collision row reproduces the maintainer's gh#572 policy on every input the -policy was written for — sweeps and explicit files are refused, generated draws -allowed — because those inputs default to design and sample respectively. It -differs only where the user declares a reading -(`--draws FILE --draws-kind -sample`, §10 D1), and there it follows the -declaration rather than the flag. - -### 4.3 Marginal reductions stay within a partition; paired reductions cross it - -The rule from `2026-08-11` §1 stands and becomes checkable per cell: +per row (`main.rs:3860-3880`, verified), holding unbounded parameters at their +default. The run spec calls it "space-filling exploration" +(`docs/camdl-run-spec.md:1145-1148`); Stage 3 corrects that sentence. The actual +space-filling designs (`[design.NAME]`, `method = "sobol" | "lhs" | +"random"`, +`batch.rs:98-107`) already run as `ParamSource::Sweep` (`batch.rs:1839`) — which +is reason 3 in running code: one iid uniform design is a partition in +`batch run` and a band in `simulate`. + +### 6.2 What each kind implies for the three decisions + +| Decision | Design coordinate | Sample | +| --------------------------------------- | -------------------------------------------------------- | ---------------------------------------------------------------- | +| A scenario sets or scales the parameter | Contradiction — refused, naming the design's origin | Counterfactual — allowed; recorded in `summary.scenario_touches` | +| Labels and provenance | A `sweep:` column and the manifest `sweep` object | Not per row; the manifest names the measure | +| Marginal reduction (quantile, mean) | Never crosses it | Crosses it | +| Paired reduction | Crosses it, matched on the band coordinate | Supplies the matching coordinate | + +The collision row is the landed gh#572 policy on every input it covers: sweeps, +design blocks and design files are refused, generated draws allowed. The one +input whose treatment changes is an exported sample read back with +`--draws-file`, which is now allowed under a scenario (W5) — the file carries a +declared measure, so a scenario over it is a counterfactual, exactly as over the +generated draws it came from. + +### 6.3 Marginal reductions stay within a group; paired reductions cross groups > A marginal reduction may cross band axes only. A partition axis is eliminated -> only by an explicit measure over its levels (none exists in camdl today) or by -> a paired reduction, which constructs a new random variable per band coordinate +> only by an explicit measure over its levels (none exists in camdl) or by a +> paired reduction, which constructs a new random variable per band coordinate > before any marginal summary. -`fit/contrasts.rs` is the paired reduction in the codebase: for each forkable -posterior draw it replays every arm under one seed, -`derive_chain_seed(seed, -draw_pos)` (`contrasts.rs:443-455`, verified), and -differences them before banding. Under this proposal the pairing coordinate has -a name: the cell's sample coordinate (and replicate). Two consequences follow, -decided here: - -- A paired reduction across a partition axis is well-defined iff the cells on - both sides share one `SampleAxis` (same rows, same measure) — the band - coordinate then denotes the same parameter vector on both sides. Pairing - across scenarios always satisfies this (a job has one sample axis). Pairing - across design points satisfies it when the design axis is crossed with a - sample axis, as in `fit predict --sweep`. -- Whether a paired reduction also enjoys variance reduction from common random - numbers depends on the seed rule (§3, §10 D4), not on validity. +A paired reduction across a partition axis is well-defined iff the cells on both +sides share one `SampleAxis` (same rows, same measure): the band coordinate then +denotes the same parameter vector on both sides. Pairing across scenarios always +satisfies this (a job has one sample axis, and the process seed excludes the +scenario, `engine.rs:11-23`). Whether pairing also enjoys the variance reduction +of common random numbers depends on the seed rule (§7.3), not on validity. -### 4.4 Out of scope, recorded so they are not re-raised +### 6.4 Out of scope -**Weighted designs.** A `[design.NAME]` parameter may carry a `prior` used for -importance weighting by `camdl voi` (`batch.rs:111-117`). A space-filling design -plus importance weights is, in principle, a weighted sample of the prior. camdl -has no weighted quantile path and `batch run` emits no quantities, so design -blocks are design axes, and a weighted-sample reading waits for a consumer. +**Weighted designs.** A `[design.NAME]` parameter may carry a `prior` for +importance weighting by `camdl voi` (`batch.rs:111-117`). camdl has no weighted +quantile path and `batch run` emits no quantities, so design blocks are design +axes; a weighted-sample reading waits for a consumer. -**`fit run --sweep`.** A profile-likelihood sweep over fits is a different code -path (a fit per point, not a simulation grid). The gh#572 instance there is -inferred from the code path in `2026-08-11` §9 and is not addressed here. +**`fit run --sweep`** fits once per point — not a simulation grid — and is +untouched. -## 5. Types +## 7. Types -### 5.1 The plan: two axes and a replicate count +### 7.1 The plan: two axes and a replicate count -`ParamPlan` replaces `ParamSource` in `SimulateJob.source` (`sim_job.rs:70`): +`ParamPlan` replaces `ParamSource` in `SimulateJob.source` (`sim_job.rs:70`). +Each axis is a sum type whose unit case is its own variant, so an axis with no +points but an origin, or a sample without a measure, cannot be written down. ```rust // sim_job.rs -/// The parameter-point structure of a job: a design axis crossed with a sample -/// axis, each replicated `replicates` times. Either axis may be the unit axis -/// (one empty point). Replaces `ParamSource`. +/// The parameter-point structure of a job: a design axis crossed with a +/// sample axis, each cell replicated `replicates` times. #[derive(Debug, Clone)] pub struct ParamPlan { design: DesignAxis, @@ -305,27 +656,28 @@ pub struct ParamPlan { /// Labelled conditions the user chose. A partition axis. #[derive(Debug, Clone)] -pub struct DesignAxis { - /// The swept/authored parameter names, sorted. Every point sets exactly - /// these names (a grid point or a file row with a missing column is refused - /// at construction). - names: Vec, - points: Vec, - origin: DesignOrigin, +pub enum DesignAxis { + Unit, + Points { + /// The design's parameter names, sorted. Every point sets exactly these. + names: Vec, + points: Vec, + origin: DesignOrigin, + }, } #[derive(Debug, Clone, PartialEq)] pub struct DesignPoint { - /// Requested values, aligned to `DesignAxis::names`. + /// Requested values, aligned to `names`. values: Vec, } +/// Where a design came from — the noun the collision diagnostic uses. #[derive(Debug, Clone)] pub enum DesignOrigin { - Unit, // no design axis - Sweep, // Cartesian grid - File { path: PathBuf }, // --draws FILE read as design - Generated { method: DesignMethod }, // batch [design.NAME]; --draws … as design + Sweep, // --sweep, [sweep], fit predict --sweep + Design { method: DesignMethod }, // [design.NAME]: "the design `NAME`" + File { path: PathBuf }, // --design FILE } /// Exchangeable draws from a stated measure. A band axis. @@ -335,48 +687,50 @@ pub enum SampleAxis { Draws { rows: Vec>, measure: Measure }, } -/// What the sample is a sample OF. A `SampleAxis::Draws` cannot exist without -/// one, which is the whole of the design/sample asymmetry (§4.1). +/// What the sample is a sample of. #[derive(Debug, Clone, PartialEq)] pub enum Measure { Posterior { fit: String, method: String }, // fit handle + stage label Prior { source: PriorSource }, // model IR or fit.toml UniformBounds, // lo + (hi-lo)·U on declared bounds - Asserted { path: PathBuf }, // --draws FILE --draws-kind sample + Asserted { path: PathBuf }, // --draws-file FILE } ``` -The fields are private to `sim_job`. The constructors are the only way in, and -each validates its invariant: +`DesignMethod` carries the block name and the method (`sobol | lhs | random`). +Fields are private to `sim_job`; the constructors are the only way in, each +validating its invariant: ```rust impl ParamPlan { pub fn point(replicates: usize) -> Self; - pub fn new(design: DesignAxis, sample: SampleAxis, replicates: usize) - -> Result; // refuses a design name absent from the model + /// Refuses a design name the model does not declare. + pub fn new(design: DesignAxis, sample: SampleAxis, replicates: usize, model: &ir::Model) + -> Result; } impl DesignAxis { - pub fn unit() -> Self; - pub fn sweep(grid: Vec>) -> Result; + /// From the CLI grammar shared with `fit predict` (§7.5). + pub fn sweep(specs: &[crate::args::types::SweepSpec]) -> Result; + /// From already-expanded rows: `[sweep]`, `[design.NAME]`, `--design FILE`. + /// Refuses ragged rows and duplicate rows. pub fn from_rows(rows: Vec>, origin: DesignOrigin) - -> Result; // refuses ragged rows and duplicate rows + -> Result; } impl SampleAxis { pub fn draws(rows: Vec>, measure: Measure) -> Self; } ``` -Duplicate design rows are refused because two partitions with identical -coordinates are indistinguishable in a tidy output; a user who wants the same -condition twice wants `--replicates`. +Duplicate design rows are refused because two groups with identical coordinates +are indistinguishable in a tidy output; the same condition twice is +`--replicates`. -A design name may also be a sample column: `fit predict --sweep beta` crosses a -`beta` design axis with posterior rows that carry `beta`. The design value wins -for that cell, which is exactly today's behaviour (the row overwrite at -`predict.rs:2239-2241`). §5.4 makes that precedence a resolver tier rather than -a map overwrite. +A design name may also be a sample column: `fit predict --sweep gamma` crosses a +`gamma` design with posterior rows that carry `gamma`, and the design value wins +(today's row overwrite, `predict.rs:2235-2237`). §7.4 makes that a resolver +tier. -### 5.2 The cell: coordinates replace `point_idx` + `point_overrides` +### 7.2 The cell: coordinates replace `point_idx` and `point_overrides` ```rust // engine.rs @@ -410,27 +764,20 @@ pub struct PartitionKey { scenario: String, design: Option } impl CellCoords { pub fn partition_key(&self) -> PartitionKey; - /// The index the seed and every per-draw lookup are keyed on (§5.3). - pub fn seed_index(&self) -> usize; + pub fn seed_index(&self) -> usize; // §7.3 } ``` `CellSpec`'s fields become private to `engine`, read through accessors, and -`plan_grid` (`engine.rs:148-193`) is the single constructor. Today there are -four construction sites (`2026-08-11` §4 counted them); the non-test one outside -`plan_grid` is `CasSink::predict_cells` (`batch.rs:1265-1300`, verified), which -re-implements the grid loop for dry-run cache classification. It is changed to -call `plan_grid` on the same job (Stage 0). The two test fixtures -(`batch.rs:2765`, `:2843`) build through a `#[cfg(test)]` constructor in -`engine`. - -`plan_grid` iterates `scenario → design point → sample → replicate`. With either -axis the unit axis, this is today's `scenario → point → rep` order exactly. +`plan_grid` (`engine.rs:148-193`) is the single constructor. The one non-test +constructor outside it is `CasSink::predict_cells` (`batch.rs:1272-1307`, +verified), which re-implements the grid loop for dry-run cache classification; +it calls `plan_grid` instead (Stage 0). The test fixtures (`batch.rs:2771`, +`:2849`) use a `#[cfg(test)]` constructor in `engine`. `plan_grid` iterates +`scenario → design point → sample → replicate`; with either axis `Unit` this is +today's `scenario → point → rep`. -### 5.3 The seed index preserves every current trajectory - -`seed_index` is the sample index when the sample axis is non-unit, otherwise the -design index, otherwise 0: +### 7.3 The seed index preserves every current trajectory ```rust fn seed_index(&self) -> usize { @@ -442,113 +789,111 @@ fn seed_index(&self) -> usize { } ``` -`process_seed_for` receives `seed_index` where it receives `point_idx` today. -Every existing job maps to the same number (verified by reading each -construction site; pinned by Stage 1's test): +`process_seed_for` (`engine.rs:52-66`) receives `seed_index` where it receives +`point_idx` today. Every existing job maps to the same number: -- `simulate --draws posterior|prior|uniform`: design unit, sample index = - today's `point_idx`. -- `simulate --draws FILE` read as design: sample unit, design index = today's +- `--draws posterior|prior|uniform`: design `Unit`, sample index = today's `point_idx`. -- `batch run`: explicit seeds, so `seeds[rep]` and the index is unused - (`engine.rs:59-60`). -- `fit predict --sweep` after Stage 4: sample index = the draw index, which is - today's `point_idx` inside each per-sweep-point job. The `total_runs == 1` - branch (`engine.rs:61-62`) changes from firing per sweep-point job to not - firing on the combined job when there are several sweep points, but - `mix_cell_seed(base, 0, 0) == base` (`util.rs:35-37`; asserted at - `engine.rs:535`), so the seed is the same. - -The same index replaces `point_idx` at the three per-draw lookups that must -follow the sample, not the flat point: the `--init-state fit` row -(`engine.rs:467-471`), `ConditionedSource::per_draw` (`predict.rs:1187`), and -`chain_of_point` (`predict.rs:1262`). The wide-format `draw` column -(`main.rs:2923`) keeps printing `seed_index + 1`, byte-identical. - -### 5.4 The resolver tier 3.5 splits in two, design over sample - -Tier 3.5 (`params_resolver.rs:880-903`) becomes 3.5a (sample row) then 3.5b -(design point), and `ValueSource::SweepPoint` splits into `ValueSource::Sample` -and `ValueSource::DesignPoint`. `SimRun.point_overrides` (`engine.rs:484`) -splits into `sample_overrides` and `design_overrides`, filled by -`build_cell_sim_run` from the cell's coordinates. The resolved values are -unchanged for every current job: no current job has both axes except -`fit predict --sweep`, where the design value already overwrote the sample value -before the resolver saw it. - -### 5.5 Parse at the boundary: where each plan is built - -| Front end | Today (`ParamSource`) | After (`ParamPlan`) | -| ------------------------- | --------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- | -| `simulate` | `main.rs:1772-1785`: `Draws{explicit_file}` or `Point` | generator → `SampleAxis::draws(rows, Measure::…)`; file → `DesignAxis::from_rows` unless `--draws-kind sample` | -| `batch run [sweep]` | `batch.rs:926-935`: `Sweep` or `Point` | `DesignAxis::sweep` | -| `batch run [design.NAME]` | `batch.rs:1900`: `Sweep` | `DesignAxis::from_rows(.., Generated{method})` | -| `fit predict` | `predict.rs:2258`: `Draws{explicit_file: None}` per sweep point | one plan: `DesignAxis::sweep` × `SampleAxis::draws(.., Posterior)` | - -The `explicit_file` field, whose doc comment carries the policy today -(`sim_job.rs:182-190`), is deleted; the policy moves to §6. - -### 5.6 Consumers read the coordinates, never the source - -| Consumer | Reads | Replaces | -| ------------------------------------------------ | ------------------------------------------ | ---------------------------------------------------------------------------- | -| Collision guard (§6) | `ParamPlan` design names vs sample columns | `check_explicit_draws_scenario_collision`, predict's guard, the gh#572 guard | -| `SimQuantities::push_cell` (`main.rs:2456`) | `cell.spec.coords().partition_key()` | `cell.spec.scenario.name()` | -| `PredictiveSink::merge_cell` (`predict.rs:1127`) | same | same, plus the enclosing `sweep_pt` loop variable | -| Quantity `Mode` (`main.rs:1917-1929`) | `ParamPlan::mode()` (§7.1) | `matches!(source, Point { replicates: 1 })` | -| `CasSink::cell_resolve` label (`batch.rs:1143`) | design names ∪ sample columns | `point_overrides.keys()` | -| Seed and per-draw lookups (§5.3) | `coords.seed_index()` | `point_idx` | - -## 6. Collision policy keys on the coordinate kind - -### 6.1 One guard, in `run_job`, for every verb - -`run_job` (`engine.rs:202-203`) calls one guard in place of -`check_explicit_draws_scenario_collision`: +- `--draws-file`: the same rows at the same indices as today's `--draws FILE`. +- `--design FILE`: sample `Unit`, design index = today's `point_idx` for the + same file under `--draws`. +- `batch run`: explicit seeds (`batch.rs:596`), so `seeds[rep]`; the index is + unused (`engine.rs:59-60`). +- `fit predict --sweep` after Stage 4: sample index = draw index, as inside + today's per-sweep-point jobs. The `total_runs == 1` branch (`engine.rs:61-62`) + no longer fires on the combined job, but `mix_cell_seed(base, 0, 0) == base` + (`util.rs:35-37`; asserted at `engine.rs:520`), so the seed is unchanged. + +`seed_index` also replaces `point_idx` at the three per-draw lookups that follow +the sample: the `--init-state fit` row (`engine.rs:452-455`), +`ConditionedSource::per_draw` (`predict.rs:1187`) and `chain_of_point` +(`predict.rs:1262`). The wide-format `draw` column (`main.rs:2931`) prints +`seed_index + 1`, byte-identical. + +### 7.4 Resolver tier 3.5 splits in two, design over sample + +Tier 3.5 (`params_resolver.rs:1095-1118`) becomes 3.5a (sample row) then 3.5b +(design point), and `ValueSource::SweepPoint` (`params_resolver.rs:193-196`, +tagged `"sweep_point"` at `:209` even for a posterior draw) splits into +`ValueSource::Sample` and `ValueSource::DesignPoint`. `SimRun.point_overrides` +(`engine.rs:469`) splits into `sample_overrides` and `design_overrides`. +Resolved values are unchanged for every current job: the only job with both axes +is `fit predict --sweep`, whose design value already overwrote the sample value. + +### 7.5 Parse at the boundary: where each plan is built + +| Front end | Today | After | +| ------------------------- | ------------------------------------------------------------------ | ------------------------------------------------------------------------------- | +| `simulate --draws …` | `main.rs:1735-1751`: `Draws { explicit_file }` or `Point` | `SampleAxis::draws(rows, Posterior \| Prior \| UniformBounds)` | +| `simulate --draws-file` | (was `--draws FILE`) | `SampleAxis::draws(rows, Asserted { path })` | +| `simulate --design` | — | `DesignAxis::from_rows(rows, File { path })` | +| `simulate --sweep` | — | `DesignAxis::sweep(&specs)` | +| `batch run [sweep]` | `batch.rs:629-644` (`sweep_source`): `Sweep` or `Point` | `DesignAxis::from_rows(points, Sweep)` | +| `batch run [design.NAME]` | `batch.rs:1839`: `Sweep` | `DesignAxis::from_rows(points, Design { method })` | +| `fit predict` | `predict.rs:2254`: `Draws { explicit_file: None }` per sweep point | one plan: `DesignAxis::sweep(&args.sweep)` × `SampleAxis::draws(.., Posterior)` | + +`simulate --sweep` reuses the CLI grammar already shared by `fit run`, +`fit +predict` and `profile`: `args::types::SweepSpec` (`args/types.rs:255`, +`V1,V2,… +| lin(min,max,n) | log10(min,max,n)`). Predict's expansion, +`expand_predict_sweep` (`predict.rs:1359`), moves into `DesignAxis::sweep` so +both verbs expand through one function. The batch TOML sweep grammar +(`batch.rs: +143`, `linspace`/`logspace`/`range`) is a different surface syntax +and stays; its expansion (`expand_sweep`, `batch.rs:293`) feeds +`DesignAxis::from_rows`. + +Flag relations in `simulate`: `--draws`, `--draws-file` are mutually exclusive +(one sample axis); `--sweep`, `--design` are mutually exclusive (one design +axis); a sample flag and a design flag compose (design × sample, as in +`fit +predict`). `--draws` accepts only `posterior`, `prior`, `uniform`; any +other value is the W9 refusal. The `explicit_file` field (`sim_job.rs:182-191`) +is deleted. + +### 7.6 Consumers read the coordinates, never the source + +| Consumer | Reads | Replaces | +| ------------------------------------------------ | ------------------------------------ | ------------------------------------------------------------------------------------ | +| Collision guard (§8) | `ParamPlan` design names and origin | `ParamSource` match at `engine.rs:367-375`; predict's call at `predict.rs:1623-1649` | +| `SimQuantities::push_cell` (`main.rs:2464`) | `cell.spec.coords().partition_key()` | `cell.spec.scenario.name()` | +| `PredictiveSink::merge_cell` (`predict.rs:1127`) | same | same, plus the enclosing `sweep_pt` loop variable | +| Quantity `Mode` (`main.rs:1931-1940`) | `ParamPlan::mode()` (§9.1) | `matches!(source, Point { replicates: 1 })` | +| `CasSink::cell_resolve` label (`batch.rs:1150`) | design names ∪ sample columns | `point_overrides.keys()` | +| Seed and per-draw lookups (§7.3) | `coords.seed_index()` | `point_idx` | + +## 8. Collision policy keys on the coordinate kind + +`check_scenario_coordinate_collision` keeps its name and its three call sites +(§4.4) but reads the plan instead of `ParamSource`: ```rust -fn check_scenario_coordinate_collision(job: &SimulateJob, model: &ir::Model) - -> Result, String> +pub fn check_scenario_coordinate_collision(job: &SimulateJob) + -> Result, String> ``` -For each scenario it computes `scenario_param_footprint` -(`params_resolver.rs: -689-725`, the shared authority both current guards already -use) and intersects it with: - -- the design axis's `names` — a non-empty intersection is a hard error naming - the parameters, the scenario, and the design's origin (the sweep, the file - path, or the design block); -- the sample axis's columns — a non-empty intersection is allowed and returned - as a `ShadowedSample { scenario, params }`. - -Because the guard reads the plan, and `fit predict --sweep` builds a plan with a -design axis after Stage 4, predict's separate guard (`predict.rs:1628-1653`) is -deleted there, and `batch run` is covered without a verb-specific call. The -footprint∩names rule is the one `2026-08-11` §1.1 argues is exact (disjointness -is required for both distinctness and denotation), so the guard is not a stopgap -for a later graded rule. +For each scenario it intersects `scenario_param_footprint` +(`params_resolver.rs:698-733`, the shared authority) with: -The graded rule proposed in the gh#572 comment (warn on partial shadowing, error -on total) is not adopted for design axes. Its motivating example — a scenario -pinning `gamma` while a sweep varies `beta` — has an empty intersection and is -already permitted; what remains in the intersection is a genuine contradiction. +- the design axis's `names` — non-empty is a hard error. `UserCoordinate` + (`params_resolver.rs:806`) becomes a view of `DesignOrigin`, so the formatter + names the origin: "the sweep", "the design `wide`", "the design file + `grid.tsv`". The `DrawsFile` variant and its wording are deleted. +- the sample axis's columns — non-empty is allowed and returned as a + `ScenarioTouch { scenario, param, action: Set | Scale }`, which `run_job` + prints (one stderr line per `set`) and the quantity accumulators copy into + `summary.scenario_touches`. -### 6.2 A shadowed sample is a counterfactual and is recorded, not hidden +Because `fit predict --sweep` builds a plan with a design axis after Stage 4, +its separate call is deleted and the engine's covers it. The footprint∩names +rule is exact, not a stopgap: `2026-08-11` §1.1 shows disjointness is required +for both distinctness (distinct coordinates run distinct models) and denotation +(each emitted coordinate equals the value that ran). -When a scenario pins a sampled column, the arm answers "the quantity under the -scenario, with the remaining parameters drawn from the measure" — a legitimate -counterfactual. It is still a change to what the band is over, so it is written -down: `run_job` prints one stderr line per shadowed scenario, and the quantities -manifest entry for that scenario carries `"shadowed": ["gamma", …]` inside its -`summary` object (§7.2). When a scenario shadows _every_ sampled column, the -band is over process noise alone; the manifest's `summary.over` then lists only -`replicate`, so the band cannot be read as parameter uncertainty (§7.2 derives -`over` per partition, not per job). +## 9. Banding keys on the coordinate kind -## 7. Banding keys on the coordinate kind - -### 7.1 `Mode` is a function of the plan +### 9.1 `Mode` is a function of the plan ```rust impl ParamPlan { @@ -561,267 +906,240 @@ impl ParamPlan { } ``` -This equals today's predicate (`main.rs:1924-1929`) on every input except a -`--draws FILE` read as design with `replicates == 1`, which becomes `Point` per -design point instead of one band pooled over the file's rows. That is the -intended change (§9, row 1). +This equals today's predicate (`main.rs:1931-1940`) on every input that still +exists; the file that used to pool (§4.1) now arrives as `--design`, has a +`Unit` sample axis, and renders in point mode per design point. -### 7.2 `simulate` partitions by design point and the manifest says what the band is over +### 9.2 `simulate` groups by design point, and the manifest says what the band is over `SimQuantities.by_scenario` becomes `by_partition: IndexMap`, keyed inside `push_cell` -from the cell as today (rejection reason 1 is not reintroduced — see §8). At -render, each partition becomes one `StackedQuantities::push_group` call whose -`DesignCoords` are built by one function from the key and the plan: - -```rust -impl DesignCoords<'_> { - fn of<'a>(key: &'a PartitionKey, plan: &'a ParamPlan, scenario_axis: bool) - -> DesignCoords<'a>; -} -``` - -`DesignCoords.sweep` already renders `sweep:` columns +from the cell. At render each group is one `StackedQuantities::push_group` whose +`DesignCoords` come from one function, +`DesignCoords::of(&PartitionKey, &ParamPlan, scenario_axis)`. +`DesignCoords. +sweep` already renders `sweep:` columns (`quantity_output.rs:227-243`) and a manifest `sweep` object (`:480-491`); -`simulate` starts populating it for a design axis. The column prefix stays -`sweep:` for every design origin (§10 D5). +`simulate` starts populating it. -Each manifest entry gains one object, additive to `camdl.quantities/v1`: +Each banded manifest entry gains one object, additive to `camdl.quantities/v1`: ```json "summary": { "over": ["sample", "replicate"], "measure": "posterior", + "source": "results/fits/sir-8a3f12b4", "n_samples": 200, "replicates_per_sample": 1, - "shadowed": [] + "scenario_touches": { "beta": "scale" } } ``` -`over` lists the band axes actually reduced for that partition; `measure` is the -`Measure` tag, or `"process"` when only replicates are reduced, or absent in -`Point` mode. This is the label gh#575 asked for. A consumer — or a figure -caption — can now say "95 % posterior predictive interval" only when `measure` -is `posterior`, and "quantiles under a uniform law on the declared bounds" when -it is `uniform_bounds`. The `n_draws` column is not renamed here; gh#575's -rename is independent. +`over` lists the band axes actually reduced in that group: `sample` unless the +axis is `Unit` or the group's scenario `set`s every sampled column; `replicate` +unless `replicates_per_sample` is 1 or the backend is ODE. `measure` is +`posterior | prior | uniform_bounds | asserted`, or `process` when only +replicates are reduced, and is absent when `over` is empty. `source` names the +fit or file where one exists. This is the label gh#575 asked for; the `n_draws` +column is not renamed here. -## 8. Why this does not reintroduce the caller-supplied key +## 10. Why this does not reintroduce the caller-supplied key Rejection reason 1 was that `BandSet::push(key, …)` let a caller choose the key. Nothing here takes a key from a caller: -- Both accumulators keep the `push_cell(&CellResult)` / - `merge_cell(&CellResult)` shape and derive `PartitionKey` from - `cell.spec.coords()`. There is no method with a key parameter. -- `CellCoords` is set only by `plan_grid`, from the `ParamPlan`; `CellSpec`'s - fields are private to `engine`. A sink cannot fabricate or edit coordinates. -- `PartitionKey` has no public constructor; `partition_key()` is its only - source. +- Both accumulators keep `push_cell(&CellResult)` / `merge_cell(&CellResult)` + and derive `PartitionKey` from `cell.spec.coords()`. No method has a key + parameter. +- `CellCoords` is set only by `plan_grid`; `CellSpec`'s fields are private to + `engine`. `PartitionKey` has no public constructor. - `StackedQuantities::push_group` still takes `DesignCoords` from its caller. - That is safe for the reason `2026-08-11` §3.5 gives — it is a rendering seam - over groups that are already separated, and a renderer cannot re-merge them — - and after this change the coordinates it receives are computed by - `DesignCoords::of` from a key the accumulator derived. - -Reason 2 (`BandKey::of` cannot serve predict) is removed by Stage 4: predict's -sweep is in the cell, so one derivation serves both verbs. Reason 3 is the -subject of §4. Reason 4 (unreachable) no longer holds: `simulate --draws FILE` -reaches the design path the day Stage 3 lands, and predict's sweep reaches it at -Stage 4. - -## 9. What changes for users - -| # | Command | Today | After | -| - | ------------------------------------------------------------------------------- | ------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| 1 | `simulate --draws FILE --quantities-out d` | one band pooled over the file's rows | one row per design point with `sweep:` columns (one band per design point over replicates under `--replicates N`); `--draws-kind sample` restores the pooled band (§10 D1) | -| 2 | `simulate --draws FILE --draws-kind sample --scenario S` (S sets a file column) | refused (flag does not exist) | allowed as a counterfactual; shadowed columns in stderr and manifest | -| 3 | `simulate --draws FILE --scenario S` (S sets a file column) | refused | refused (unchanged; message names the design origin) | -| 4 | `batch run` sweep or design block × a scenario setting a swept parameter | runs; cells identical | refused (the gh#572 outcome) | -| 5 | `simulate --draws uniform | prior | posterior --scenario S` (S sets a drawn column) | -| 6 | Any banded quantities output | no statement of what the band is over | manifest `summary` object (additive) | -| 7 | `fit predict --sweep …` | — | byte-identical outputs (Stage 4 is required to be) | -| 8 | `run.json` provenance tag for a draw or sweep value, where written | `sweep_point` | `sample` or `design_point` | - -Row 1 is loud rather than silent: a per-point table has a different header and -`n_rows × n_points` rows, so it cannot be mistaken for a band. That is the -property that makes design the safe default (§10 D1). + That is safe for the reason `2026-08-11` §3.5 gives — it renders groups that + are already separated and cannot re-merge them — and the coordinates it + receives are computed by `DesignCoords::of` from a key the accumulator + derived. + +Reason 2 is removed by Stage 4 (predict's sweep is in the cell). Reason 3 is the +subject of §6. Reason 4 no longer holds: `--design` and `simulate --sweep` reach +the design path in Stage 3. + +## 11. What changes for users + +| # | Command | Today | After | +| -- | ------------------------------------------------------------------ | --------------------------------------------------------- | -------------------------------------------------------------------------------- | +| 1 | `simulate --draws FILE …` | runs; pools a grid into one band under `--quantities-out` | refused, naming `--design FILE` and `--draws-file FILE` (W9) | +| 2 | `simulate --draws-file FILE` | (does not exist) | today's `--draws FILE` behaviour as a sample; allowed under a scenario (W5) | +| 3 | `simulate --design FILE`, `simulate --sweep P=GRID` | (do not exist) | one group per point, `sweep:` columns; scenarios on those params refused | +| 4 | `simulate --draws-out PATH` | writes the file | also prints `reuse as a sample with --draws-file PATH` | +| 5 | `batch run [design.NAME]` × colliding scenario | refused as "the sweep" | refused as "the design `NAME`" | +| 6 | Scenario setting a column of generated draws | runs silently | runs; one stderr line; `summary.scenario_touches` | +| 7 | Any banded quantities output | no statement of what the band is over | manifest `summary` object (additive) | +| 8 | `fit predict --sweep …` | — | byte-identical outputs | +| 9 | `--design-from` with a file | `--draws FILE` | `--draws-file` or `--design`; writes `design_from.json` recording the kind (W11) | +| 10 | `run.json` provenance tag for a draw or sweep value, where written | `sweep_point` | `sample` or `design_point` | **Run identity does not re-key.** Identity hashes resolved parameter values and -the process seed (`resolve.rs:202-233`). §5.3 shows every current cell keeps its -seed; §5.4 shows every current cell keeps its resolved values. The provenance -tag in row 8 is recorded, not hashed, for `sim` leaves (`run.json.inputs` is -display-only, commit `2e141e0a`); for fit and profile leaves, whether -`parameters_provenance` (`run_meta.rs:644`) enters any hash is _not verified_ — -Stage 5 carries the test that settles it. - -**IR and goldens are unaffected.** All changes are in `rust/crates/cli`. No -`ir/VERSION` bump, no `ocaml/` change, no golden regeneration. - -## 10. Decisions for the maintainer - -**D1 — What does an explicit `--draws FILE` default to, and how is it -declared?** - -- (a) Default design; `--draws-kind sample` asserts a measure - (`Measure:: - Asserted`). Collision behaviour for undeclared files is - unchanged from today. Breaks one documented recipe's output shape: "Posterior - predictive from a draws file" (`docs/camdl-run-spec.md:1952-1954`) would emit - per-draw rows under `--quantities-out` until `--draws-kind sample` is added. - Loud, not silent. -- (b) Default sample; `--draws-kind design` for grids. No recipe breaks, but a - hand-authored grid passed without the flag is banded — the silent wrong answer - reason 3 names. -- (c) No default: refuse `--draws FILE` without `--draws-kind` whenever the kind - is load-bearing (`--quantities-out`, or a scenario footprint touching a file - column). Most honest; adds a required flag to existing invocations in those - cases. -- In-file declaration (a directive line in the TSV header) is rejected: the - reader treats line 1 as the header (`main.rs:4384-4396`), so a directive would - break every older camdl and every tool reading these files, and `--draws-out` - round-trips would need a format change. - -Recommendation: (a). A per-point table in place of a band is a visible change a -user will ask about; a band over a grid is not. `--draws-out` should print the -`--draws-kind sample` hint when it writes a generated sample, so round-trips -stay one flag away. Confidence: **need you** — it changes a documented recipe. - -**D2 — Is `--draws uniform` a sample or a design by default?** - -- (a) Sample with `Measure::UniformBounds`: bands as today; the manifest says - `uniform_bounds`, never `posterior`. `--draws-kind design` gives per-point - rows. -- (b) Design: per-point rows by default, which is what sensitivity exploration - usually wants, at the cost of changing today's output and of large tables - (`-n 500` × time rows per quantity). - -Recommendation: (a). The law is real (§3), the band is a correct Monte Carlo -estimate of its push-forward, and the new `summary.measure` field stops it being -read as a credible interval. Confidence: **leaning**. - -**D3 — Move `fit predict`'s sweep onto the grid (Stage 4) now, or leave predict -folding its sweep into draw rows?** - -- (a) Move it: predict builds one plan (sweep design × posterior sample), runs - all scenarios and sweep points in one `run_job`, and deletes its private guard - and the fresh-sink-per-sweep-point loop (`predict.rs:2193-2330`). One - derivation of the partition key serves both verbs. -- (b) Leave it: `PartitionKey` is computed from the cell in `simulate` and from - a loop variable in predict — the §2.2 asymmetry persists, and so does the - second guard. - -Recommendation: (a), gated on the byte-identity test in Stage 4. `2026-08-11` §4 -declined this because only the scenario half of the key was reachable; Stage 3 -makes the design half reachable in `simulate`, which removes that objection. The -conditioned-read routing (`conditioned_here`, `predict.rs:934-943`) becomes a -function of the cell's `PartitionKey` (conditioned iff scenario is the -conditioned arm and design is unit) — the same three conditions it checks today. -Confidence: **leaning**. - -**D4 — Should design points share common random numbers?** - -- (a) Keep today's seeds (§5.3): `batch run` and `fit predict` share seeds - across design points; a design read from a file in `simulate` does not. - Byte-neutral. -- (b) Key the seed on `(sample index, rep)` only, so every design axis shares - seeds across its points, as `batch run` and predict already do. Makes paired - comparisons across design points low-variance everywhere; changes the - trajectories (and therefore the run ids) of every `simulate --draws FILE` run - with more than one row. - -Recommendation: (a) in this proposal; file (b) as its own issue, because it is a -deliberate re-key with its own justification and should not ride in on a type -change. Confidence: **solid** on not bundling it; **need you** on whether (b) is -wanted at all. - -**D5 — What column prefix does a non-sweep design coordinate use?** - -- (a) `sweep:` for every design origin: no change to predict headers or - the manifest `sweep` object; the prefix means "design coordinate" in the docs. -- (b) `design:` everywhere: the name is accurate for a file or a - generated design, and pre-1.0 renames are cheap (`VERSIONING.md`), but it - changes `fit predict`'s headers and the manifest key, which - `fit_predict_sweep.rs:191` pins. - -Recommendation: (a). Confidence: **leaning**. - -## 11. Staged implementation - -Each stage lands alone with a green `make test`. Stages 0-2 and 4 are -byte-neutral; 3 and 5 carry the user-visible changes in §9. - -**Stage 0 — one constructor for `CellSpec`.** `CasSink::predict_cells` -(`batch.rs:1265-1300`) calls `plan_grid` on the job instead of re-looping; the -two test fixtures go through a `#[cfg(test)]` constructor. _Test:_ for a sweep × -two scenarios × three seeds manifest, the dry-run hit/miss classification and -every predicted `run_id` equal those of the real run (a new CLI test running -`--dry-run`, then the run, then `--dry-run` again, asserting all-miss then -all-hit with identical leaf paths). - -**Stage 1 — `ParamPlan` and `CellCoords`.** Replace `ParamSource`; split -`point_overrides`; add `seed_index`; migrate the §5.6 consumers mechanically, -keeping every behaviour. _Tests:_ `tests/determinism_pin.rs` unchanged and -green; a new unit test in `engine` that plans the four current job shapes -(point, generated draws, draws file, sweep with explicit seeds) and asserts each -cell's `process_seed` equals `process_seed_for` evaluated with the old -`point_idx` — the old formula kept in the test as the oracle; an A/B over +the process seed (`resolve.rs:202-233`, verified); §7.3 keeps every seed and +§7.4 every resolved value. The provenance tag in row 10 is recorded, not hashed, +for `sim` leaves (`run.json.inputs` is display-only, `resolve.rs:238-245`); for +fit and profile leaves, whether `parameters_provenance` (`run_meta.rs:644`) +enters any hash is _not verified_ — Stage 6 carries the test that settles it. + +**IR and goldens are unaffected.** All changes are in `rust/crates/cli` and +docs. No `ir/VERSION` bump, no `ocaml/` change, no golden regeneration. + +## 12. Staged implementation + +Each stage lands alone with a green `make test`. Stages 0, 1 and 5 are +byte-neutral; 2, 3 and 4 carry the user-visible changes in §11. + +**Stage 0 — one constructor for `CellSpec`.** `CasSink::predict_cells` calls +`plan_grid`; the test fixtures go through a `#[cfg(test)]` constructor. _Test:_ +for a sweep × two scenarios × three seeds manifest, `--dry-run`, run, +`--dry-run` again: all-miss, then all-hit with identical leaf paths. + +**Stage 1 — `ParamPlan` and `CellCoords`.** Replace `ParamSource` (front ends +build the plan the flags they have today imply; `--draws FILE` still accepted as +an `Asserted` sample so behaviour is unchanged); split `point_overrides`; add +`seed_index`; migrate the §7.6 consumers mechanically. _Tests:_ +`tests/determinism_pin.rs` green and unchanged; a unit test planning point, +generated-draws, draws-file and explicit-seed sweep jobs and asserting each +cell's `process_seed` equals `process_seed_for` with the old `point_idx` (the +old formula kept in the test as the oracle); an A/B of `simulate --draws uniform -n 5 --scenario a,b` and a `batch run` sweep, diffing every artifact including `run.json`. -**Stage 2 — the collision guard keys on the plan.** §6.1 replaces -`check_explicit_draws_scenario_collision`; the in-flight gh#572 guard is -superseded or rebased onto it. Predict's guard stays until Stage 4. _Tests:_ the -gh#572 red test (a `batch run` sweep and a `[design]` block each colliding with -a scenario → non-zero exit naming both sources); `--draws FILE` collision still -refused; `--draws uniform --scenario S` with S pinning a drawn column → exit 0 -and one stderr line naming the shadowed column. Mutation check: make the guard -treat the design axis as a sample axis and confirm the batch test goes red. - -**Stage 3 — design partitions and the `summary` block in `simulate`.** -`--draws-kind`, `Measure` construction at the `simulate` boundary, -`ParamPlan::mode`, `by_partition`, `DesignCoords::of`, the manifest `summary`. -_Tests:_ `--draws FILE` (three rows) `--quantities-out` → a scalar quantity file -with three rows, `sweep:` columns, point-mode header; the same with -`--draws-kind sample` → one band row with `n_draws = 3`; `--draws uniform -n 5` -→ `summary.measure == "uniform_bounds"`, `over == ["sample"]`; `--replicates 4` -with no draws → `measure == "process"`. Mutation check: key `by_partition` on -the scenario alone and confirm the three-row assertion fails on the row count, -not the header. - -**Stage 4 — `fit predict` on the grid (if D3 is (a)).** One plan, one `run_job` -for all scenarios and sweep points; `merge_cell` keys on `PartitionKey`; -predict's guard deleted; `conditioned_here` reads the key. _Tests:_ -byte-identity A/B of every predict artifact (predictive TSVs, quantities TSVs, -all manifests) for `--sweep k=… --scenario a,b` with and without a conditioned -fit, same seed; the existing header pin (`fit_predict_sweep.rs:191`); the -existing predict sweep-collision test, now passing through the shared guard. - -**Stage 5 — provenance.** `ValueSource::{Sample, DesignPoint}`; the per-cell -manifest records `requested` alongside effective values for design coordinates -(§12). _Test:_ one fit leaf and one `sim` leaf before and after — `run_id` -unchanged, the provenance tag changed — which settles the unverified question in -§9. - -## 12. Requested and effective values for a future scale × sweep composition - -Today a scenario that scales a swept parameter is refused (§6.1), so a design +**Stage 2 — the flag split and the guard on the plan.** Add `--draws-file` and +`--design`; refuse a bare `--draws FILE` (W9); `--draws-out` prints the +round-trip hint; `DesignOrigin` naming in the formatter; a sample never collides +and scenario touches are returned. Replaces the landed guard's `ParamSource` +keying (`engine.rs:367-375`) — the policy is unchanged. Doc change in the same +stage: the run-spec recipe "Posterior predictive from a draws file" +(`docs/camdl-run-spec.md:1976-1978`) becomes `--draws-file draws.tsv`, the +`--draws` help (`args/mod.rs:386-390`) and `--draws-out` help +(`args/mod.rs:412-417`) are rewritten, and the `--design-from` help names both +flags. _Tests:_ the refusal message for `--draws grid.tsv`, asserted verbatim +including both replacement flags; a round trip — +`--draws uniform -n 5 +--draws-out u.tsv`, then `--draws-file u.tsv` with the +same seed — produces leaves with the same `run_id`s as the first run; +`--draws-file u.tsv --scenario +S` (S sets a column) exits 0 with one stderr +line; `--design u.tsv --scenario S` is refused naming "the design file `u.tsv`"; +the gh#572 tests (`tests/scenario_coordinate_collision_gh572.rs`) updated for +the new nouns, with a `[design.NAME]` case asserting "the design `NAME`". +Mutation check: make the guard treat the design axis as a sample and confirm the +batch test goes red. + +**Stage 3 — design groups, `simulate --sweep`, and the `summary` block.** +`simulate --sweep` through `DesignAxis::sweep` (the lifted predict expander); +`ParamPlan::mode`; `by_partition`; `DesignCoords::of`; the manifest `summary`; +the run-spec sentence on `--draws uniform` (`docs/camdl-run-spec.md:1145-1148`) +corrected to "an iid uniform sample over declared bounds". _Tests:_ +`--sweep beta=0.15,0.3,0.6 --backend ode --dt 0.1 --quantities-out q` → three +rows `sweep:beta value` with `peak_I` 70, 304, 536 (the measured values of §3); +`--design grid.tsv` with the same three rows → byte-identical files; the same +with `--replicates 4 --backend chain_binomial` → three band rows, `n_draws = 4`, +`measure == "process"`; `--draws uniform -n 5` → `measure == "uniform_bounds"`, +`over == ["sample"]`; `--sweep` and `--design` together refused by clap. +Mutation check: key `by_partition` on the scenario alone and confirm the +three-row assertion fails on the row count, not the header. + +**Stage 4 — `fit predict` on the grid.** One plan, one `run_job` for all +scenarios and sweep points; `merge_cell` keys on `PartitionKey`; predict's guard +call deleted; `conditioned_here` reads the key (conditioned iff the scenario is +the conditioned arm and the design is `Unit` — its three conditions today). +_Tests:_ byte-identity A/B of every predict artifact (predictive TSVs, +quantities TSVs, all manifests, contrasts) for `--sweep k=… --scenario a,b` with +and without a conditioned fit, same seed; the header pin in +`tests/fit_predict_sweep.rs`; the predict sweep-collision test, now through the +engine's guard. The manifest `summary` is added in a separate commit after the +A/B passes. + +**Stage 5 — `--design-from` on the new axes.** Accept `--draws-file`, +`--design`, `--sweep`; write `design_from.json`. Byte-neutral for the dataset +files. _Test:_ `--design-from fit.toml --design truths.tsv` writes one `ds_NN/` +per row with `design_from.json` recording `"axis": "design"`; with +`--draws prior -n 3`, `"axis": "sample", "measure": "prior"`. + +**Stage 6 — provenance.** `ValueSource::{Sample, DesignPoint}`; the per-group +manifest records `requested` alongside effective design values (§14). _Test:_ +one fit leaf and one `sim` leaf before and after — `run_id` unchanged, tag +changed — which settles the unverified question in §11. + +## 13. Decisions + +**The ambiguous `--draws FILE` splits by axis.** A file's rows are either a +sample the user stands behind or conditions to compare, and no content of the +file says which. Rather than default one way and let the other become a silent +wrong answer, the flag names the axis: `--draws-file FILE` is a sample +(`Measure::Asserted`), `--design FILE` is a design (`DesignOrigin::File`), and a +bare `--draws ` is refused with a message naming both. This follows the +alpha posture — break with the replacement spelled out, no shim — and removes +the need for any modifier flag. `--draws posterior|prior|uniform` is unchanged. +`--draws-out` prints `--draws-file ` as the round-trip hint, since a +generated sample read back is still a sample. An in-file declaration was not +chosen: the reader takes line 1 as the header (`main.rs:4392-4399`), so a +directive line would break older camdl and every other reader of these files. + +**`simulate` gains `--sweep`.** Parity with `fit predict`: the same `PARAM=GRID` +grammar (`args::types::SweepSpec`) and one expansion function shared by both +verbs, producing a `DesignAxis` with origin `Sweep`. It composes with a sample +flag (design × sample) and excludes `--design` (one design axis per job). + +**`--draws uniform` is a sample with `Measure::UniformBounds`.** The uniform law +on the declared box is a real measure (§6.1), so a band over it is a correct +Monte Carlo estimate of its push-forward; what it is not is a belief, and the +manifest's `"measure": "uniform_bounds"` keeps it from being captioned as one. +Per-point rows remain one `--draws-out` / `--design` round trip away (W3). + +**`fit predict`'s sweep moves onto the grid (Stage 4), gated on byte identity.** +This is what puts predict's partition coordinate in the cell, lets one guard and +one grouping derivation serve both verbs, and removes rejection reason 2. The +objection recorded in `2026-08-11` §4 — only the scenario half of the key was +reachable — no longer holds once `simulate --sweep` and `--design` make the +design half reachable. + +**Seeds stay as they are.** Design points share common random numbers in +`batch run` (explicit seeds) and in `fit predict` (the seed follows the draw +index), and not for a design read from a file in `simulate`, where the row index +enters the seed (§7.3). Changing that would alter trajectories and re-key those +runs, and deserves its own justification; it is out of scope here and should be +filed as a separate issue. + +**Every design origin uses the `sweep:` column prefix.** One prefix for +"design coordinate" leaves `fit predict` headers and the manifest `sweep` object +unchanged; the documentation defines the prefix as a design coordinate +regardless of origin. + +**`--design-from` accepts every axis and records which.** It writes one dataset +per parameter point and reduces nothing, so neither axis changes its datasets; +but whether downstream analysis may pool across them depends on the kind, so the +kind is written into `design_from.json` (W11). + +**Contrasts stay sweep-agnostic.** A contrast forks from a smoothed state that +exists only for the un-swept model (W7). Pairing within design points is +well-defined in the coordinate algebra and is left to a free-forward contrast +feature, to be filed separately. + +## 14. Requested and effective values for a future scale × sweep composition + +A scenario that scales a design parameter is refused (§8), so a design coordinate's requested value always equals the value the cell ran. If a later -proposal admits composition — sweep `mu ∈ {1, 2, 3}` under -`scale = { mu = 2.0 }` running `{2, 4, 6}` — the representation is already -decided here: - -- **Identity hashes only effective values.** It does already (§3). Two cells - whose effective values coincide under different requested coordinates share a - `run_id` and are one computation in the store; that is correct. -- **The partition key is the requested coordinate.** It is the index of the axis - the user chose (`DesignCoord::Point { idx }`), not a value. -- **A column denotes one thing.** `sweep:mu` keeps meaning the requested grid +proposal admits composition — sweep `beta ∈ {0.15, 0.3, 0.6}` under `distancing` +running `{0.09, 0.18, 0.36}` — the representation is fixed here: + +- **Identity hashes only effective values.** It does already + (`batch.rs:1141-1153`, `resolve.rs:231`, verified). Cells whose effective + values coincide under different requested coordinates share a `run_id` and are + one computation in the store. +- **The grouping key is the requested coordinate** — the index of the axis the + user chose (`DesignCoord::Point { idx }`), not a value. +- **A column denotes one thing.** `sweep:beta` keeps meaning the requested grid value; the effective value, where it differs, is reported under a separately - named column and in the manifest's per-cell `requested`/`effective` pair - (Stage 5 writes both; they are equal until composition exists). The denotation - condition of `2026-08-11` §1.1 is then satisfied by naming, not by forbidding. + named column and in the manifest's per-group `requested`/`effective` pair + (Stage 6 writes both; they are equal until composition exists). Admitting composition is out of scope; this section fixes only where its -metadata would go, so Stage 5 records the right shape once. +metadata would go. From 20ddf3113fb2b406c580939c24dfc3e0766762f6 Mon Sep 17 00:00:00 2001 From: Vince Buffalo Date: Tue, 29 Sep 2026 11:37:54 -0700 Subject: [PATCH 3/5] docs(proposal): apply the coordinate-kind review decisions Renames --design to --sweep-file, so flags pair by axis (--sweep / --sweep-file, --draws / --draws-file). Design coordinates leave the process seed, giving common random numbers across design points. Adds a normative output schema (point_id, exact sweep: values, a band column, a self-describing manifest), landed as its own stage before fit predict's grid move; a provenance sidecar for --draws-out and --draws-file, with -n subsampling a file; and refuses duplicate rows only in --sweep-file. Sampled columns are now the ones the measure varies. A scenario, --param or design value that sets one is recorded as an imposition in plain words, and --param joins the collision footprint. Quantity mode is decided per quantity, so ODE replicates and one-row samples no longer render zero-width bands. The staged plan is reordered so no stage pools design points or drops a refusal, and byte-neutral checks compare level hashes and bytes rather than run_ids, which fold the git hash. Workflows are updated and extended with --param on a swept parameter and a sidecar round trip. Follow-ups are listed to be filed. Relates-to: gh#562, gh#572, gh#575, gh#630, gh#948, gh#949 --- ...-09-27-coordinate-kind-design-vs-sample.md | 1541 +++++++++++------ 1 file changed, 998 insertions(+), 543 deletions(-) diff --git a/docs/dev/proposals/2026-09-27-coordinate-kind-design-vs-sample.md b/docs/dev/proposals/2026-09-27-coordinate-kind-design-vs-sample.md index 44f34e31..5f84c221 100644 --- a/docs/dev/proposals/2026-09-27-coordinate-kind-design-vs-sample.md +++ b/docs/dev/proposals/2026-09-27-coordinate-kind-design-vs-sample.md @@ -1,13 +1,17 @@ # A parameter point is a design coordinate or a sample, and the cell says which - Date: 2026-09-27 -- Status: accepted — every call is recorded in §13; ready to implement -- Relates-to: gh#562, gh#572, gh#575, gh#948, +- Status: accepted — every call is recorded in §16; ready to implement +- Relates-to: gh#562, gh#572, gh#575, gh#630, gh#948, gh#949, `2026-06-27-sealed-fit-packets-handles-and-override-algebra.md` (§4), `2026-08-11-scenario-banding-in-simulate.md` (implemented) - Builds on: an untracked architecture-review note on gh#562 (2026-08-11). Every claim this proposal uses from it is restated in §4; the note is not needed to read this document. +- Prerequisite: gh#949 (generated prior/uniform rows carry parameters the + measure does not sample, so a scenario's value leaks into other arms and + `--draws-out` exports it) is being fixed separately. §7.2's "varying columns" + builds on that fix. All code references are against `origin/main` at `ebe845a0`. A reference marked _verified_ was read at that line; _inferred_ means reasoned from the code but @@ -30,24 +34,28 @@ hand-authored grid as a sample; an iid uniform design is a partition in This proposal replaces `ParamSource` with a `ParamPlan` holding two typed axes — a **design axis** and a **sample axis** — and splits the cell's single `point_overrides` map into a design coordinate and a sample coordinate. -Collision policy, leaf labels and provenance, and banding then key off one -property the cell carries, set by one constructor. On the command line the -ambiguous `--draws FILE` splits by axis into `--draws-file FILE` (a sample) and -`--design FILE` (design points); `simulate` gains `--sweep`; `fit predict`'s -sweep moves onto the grid, so both verbs put their partition coordinates in the -cell. Nothing re-keys run identity; the IR and goldens are untouched. +Collision policy, labels and provenance, and banding then key off one property +the cell carries, set by one constructor. On the command line the flags come in +axis-named pairs: `--sweep` / `--sweep-file` build the design axis, `--draws` / +`--draws-file` build the sample axis, and a bare `--draws ` is refused +naming both file flags. Design coordinates stop entering the process seed, so +design points share common random numbers. The output gains a normative schema: +a `point_id` on every design-bearing row, exact design coordinates, a `band` +column saying what each band is over, and a self-describing manifest. +`fit predict`'s sweep moves onto the grid, so both verbs put their partition +coordinates in the cell. The IR and goldens are untouched. ## 1. How the user works with each axis -Every simulation grid is a product of four axes. The user controls each with a -distinct flag or manifest key, and each plays one role in the output. +Every simulation grid is a product of four axes. The user controls each with +distinct flags or manifest keys, and each plays one role in the output. -| Axis | `simulate` (after) | `batch run` manifest | `fit predict` | Output role | -| ------------- | ---------------------------------------------------------- | ---------------------------- | ----------------------------- | ------------------------------------------------ | -| **Scenario** | `--scenario a,b` | `[[scenario]]` | `--scenario a,b` (+ `fitted`) | group: `scenario` column | -| **Design** | `--sweep beta=…` or `--design FILE` | `[sweep]` or `[design.NAME]` | `--sweep beta=…` | group: `sweep:` columns | -| **Sample** | `--draws posterior\|prior\|uniform` or `--draws-file FILE` | none | always the fit's posterior | pooled into the band; manifest names the measure | -| **Replicate** | `--replicates N` or `--seeds a,b,…` | `seeds = {…}` | one per draw | pooled into the band | +| Axis | `simulate` (after) | `batch run` manifest | `fit predict` | Output role | +| ------------- | ---------------------------------------------------------- | ---------------------------- | ----------------------------- | ------------------------------------------------- | +| **Scenario** | `--scenario a,b` | `[[scenario]]` | `--scenario a,b` (+ `fitted`) | group: `scenario` column | +| **Design** | `--sweep P=GRID` or `--sweep-file FILE` | `[sweep]` or `[design.NAME]` | `--sweep P=GRID` | group: `point_id` + `sweep:` columns | +| **Sample** | `--draws posterior\|prior\|uniform` or `--draws-file FILE` | none | always the fit's posterior | pooled into the band; `band` column names measure | +| **Replicate** | `--replicates N` or `--seeds a,b,…` | `seeds = {…}` | one per draw | pooled into the band | The rule the output follows: **scenario and design make groups (rows); sample and replicate are pooled into the band within each group.** A quantity file has @@ -56,12 +64,13 @@ summarize the sample and replicate cells inside that group only. What stays the same: scenarios, replicates, seeds, `--draws -posterior|prior|uniform`, `batch run` manifests, and `fit predict`'s -output bytes. What changes: a bare `--draws FILE` is refused with a message -naming its two replacements; `simulate --sweep` and `simulate --design` are new -and emit `sweep:` columns; a scenario that touches a sampled column is -recorded in the manifest; every banded manifest entry says what its band is over -(§9.2). +posterior|prior|uniform`, `batch run` manifests and their store leaves. +What changes: a bare `--draws FILE` is refused with a message naming +`--sweep-file` and `--draws-file`; `simulate --sweep` and `--sweep-file` are +new; banded files gain a `band` column and design-bearing rows a `point_id` (in +`fit predict` too, as its own commit); a scenario, a `--param`, or a design +value that touches a varying sampled column is recorded; the manifest says what +every entry is. ## 2. Terms @@ -87,16 +96,25 @@ bounds. Samples form a band axis. The measure is what makes a quantile over them mean something: it estimates a quantile of the push-forward of that measure through the model. +The **varying columns** of a sample are the parameters the measure actually +varies: for a posterior, the estimated parameters; for a prior, the parameters +with a prior; for `uniform`, the bounded parameters actually drawn; for a file, +its non-constant columns (or the list its provenance sidecar records, §12.4). +Other columns a row happens to carry are constants, not part of the sample. + A **marginal reduction** summarizes a set of cells treating them as exchangeable draws from one distribution — a quantile, a mean. A **paired reduction** combines cells from _different_ values of a partition axis that share a band coordinate — draw _i_ of the baseline arm with draw _i_ of the intervention arm — to construct a new random variable before any marginal summary. -A scenario **shadows** a sampled column when it `set`s that parameter: every -sample then runs at the scenario's value, so that column's variation is removed -from the band. A scenario that `scale`s a sampled column **rescales** it: the -variation is kept and multiplied. +An **imposition** is a value set on a varying sampled column by something other +than the measure — a scenario `set`, a `--param`, or a design coordinate. Every +sample then runs at that value while its other parameters keep their sampled +values. That is an intervention on the sampled parameter, not a conditional of +the measure on it; with correlated parameters the resulting band can be wider or +narrower than the unimposed one. A scenario `scale` on a varying column +**rescales** it: the variation is kept and multiplied. ## 3. The running example @@ -160,11 +178,12 @@ conditions; it describes no distribution anyone stated. parameters in every posterior draw row (`predict.rs:2235-2237`) and runs one `ParamSource::Draws` job per scenario (`predict.rs:2254`, `:2262`). The sweep value lives in the loop variable of `for sweep_pt in &sweep_points` -(`predict.rs:2188`) and reaches the renderer directly (`predict.rs:2297-2300`). -Every predict cell looks like a posterior draw; its partition coordinate is not -in the cell, and its collision check must run separately -(`predict.rs:1623-1649`, verified, whose comment says so: "predict folds the -sweep into the draw rows, which the engine sees as generated draws"). +(`predict.rs:2188`), reaches the renderer directly (`predict.rs:2297-2300`), and +keys `FreeForwardCell` (`predict.rs:2340-2342`). Every predict cell looks like a +posterior draw; its partition coordinate is not in the cell, and its collision +check runs separately (`predict.rs:1623-1649`, verified), whose comment says so: +"predict folds the sweep into the draw rows, which the engine sees as generated +draws". ### 4.3 The rejected design, and the four reasons @@ -198,9 +217,10 @@ It was rejected on four grounds (recorded in `2026-08-11` §4): gh#572 (a scenario silently overriding a swept or file-supplied parameter) is fixed on `main`. `engine::check_scenario_coordinate_collision` -(`engine.rs:364-390`, verified) runs from `run_job` (`engine.rs:203`), from -`batch::batch_job` (`batch.rs:605`) so dry-run and status refuse too, and from -`simulate --dry-run` (`main.rs:1799`). Its wording comes from one formatter, +(`engine.rs:364-390`, verified; signature `-> Result<(), String>`) is called +from three sites: `run_job` (`engine.rs:203`), `batch::batch_job` +(`batch.rs:605`, so dry-run and status refuse too), and `simulate --dry-run` +(`main.rs:1799`). Its wording comes from one formatter, `params_resolver::scenario_coordinate_collision` (`params_resolver.rs:842-940`), which `fit predict` also calls (`predict.rs:1637`). The policy is the maintainer's: a scenario touching a _user-authored_ coordinate — a sweep, a @@ -211,32 +231,34 @@ The guard decides which is which by matching `ParamSource` (`engine.rs:367-375`): `Sweep` and `Draws { explicit_file: Some(_) }` are checked, everything else passes. So the correct policy is keyed on the proxy reason 3 rejects, and it inherits the proxy's blind spots: a `[design.NAME]` -block is reported as "the sweep" (`params_resolver.rs:881`, because the design -path builds a `Sweep`, `batch.rs:1839`), and predict's sweep needs its own call -because the engine cannot see it. This proposal keeps the policy and replaces -the key (§8). +block is reported as "the sweep" (the noun is chosen at +`params_resolver.rs:860`, and the design path builds a `Sweep`, +`batch.rs:1839`), and predict's sweep needs its own call because the engine +cannot see it. The existing test for the design-block refusal +(`tests/scenario_coordinate_collision_gh572.rs:235-264`) pins the "sweep" +wording. This proposal keeps the policy and replaces the key (§8). ## 5. Workflows, end to end Each workflow gives the modelling question, the commands, the output headers -with example rows, the manifest `summary` object (§9.2), and what each relevant -collision does. File paths are relative to the `--quantities-out` directory `q/` -unless stated. Series quantities are omitted for brevity; `peak_I` and `final_R` -are scalars, so each file has one row per group. +with example rows, the manifest entry fields that matter (§9.3 defines them +all), and what each relevant collision does. Paths are relative to the +`--quantities-out` directory `q/`. `peak_I` and `final_R` are scalars, so each +file has one row per group; only `peak_I` is shown. ### W1 — Sensitivity sweep: how does the peak depend on `beta`? **Today** there is no `simulate --sweep`; the nearest spelling is a draws file, -which pools (§4.1). `batch run` sweeps but emits no quantities. +which pools (§4.1). `batch run` sweeps but emits no quantities (follow-up F3). -**After**, three spellings, one result: +**After**, three spellings; the first two produce identical files: ``` camdl simulate sir.camdl --params base.toml --sweep beta=0.15,0.3,0.6 \ --backend ode --dt 0.1 --quantities-out q -camdl simulate sir.camdl --params base.toml --design grid.tsv \ - --backend ode --dt 0.1 --quantities-out q # grid.tsv: one column `beta` +camdl simulate sir.camdl --params base.toml --sweep-file grid.tsv \ + --backend ode --dt 0.1 --quantities-out q # grid.tsv: one column `beta` # batch.toml [sweep] @@ -246,33 +268,40 @@ beta = [0.15, 0.3, 0.6] `q/quantities/peak_I.tsv` (point mode — one realization per group): ``` -sweep:beta value -0.15 70 -0.3 304 -0.6 536 +point_id sweep:beta value +0 0.15 70 +1 0.3 304 +2 0.6 536 ``` -Manifest entry for `peak_I`: `"sweep": {"beta": 0.15}` per group as today in -`fit predict`, and no `summary` object, because point mode reduces nothing. +Manifest entry: `"mode": "point"`, +`"partition_columns": ["point_id", +"sweep:beta"]`, `"summary": {"over": [], …}`. **With `--replicates 20 --backend chain_binomial`**, each design point gets its -own band over process noise: +own band over process noise, and all three points share the same 20 process +seeds (common random numbers, §7.3): ``` -sweep:beta n_draws q05 q25 q50 q75 q95 -0.15 20 … … … … … -0.3 20 … … … … … -0.6 20 … … … … … +point_id sweep:beta band n_draws q05 q25 q50 q75 q95 +0 0.15 process 20 … … … … … +1 0.3 process 20 … … … … … +2 0.6 process 20 … … … … … ``` -`"summary": {"over": ["replicate"], "measure": "process", "n_samples": 0, -"replicates_per_sample": 20, "scenario_touches": {}}`. +`"mode": "banded"`, +`"summary": {"over": ["replicate"], "measure": "process", +"n_samples": null, "replicates_per_sample": 20, …}`. +Because the design index does not enter the seed, adding `beta=0.45` to the +sweep leaves the other three points' trajectories byte-identical. **Collisions.** `--scenario pinned` (`set beta`) or `--scenario distancing` (`scale beta`) touches the swept parameter and is refused before any cell runs -(W10 shows the message). `--sweep gamma=0.05,0.1,0.2 --scenario distancing` -touches distinct parameters and runs: six groups, `scenario sweep:gamma -value`. +(W10). `--param beta=0.5` is refused the same way (W12). +`--sweep gamma=0.05,0.1,0.2 --scenario distancing` touches distinct parameters +and runs: three groups, header `scenario point_id sweep:gamma value` (one +`--scenario` is one arm, so the `scenario` column holds `distancing` on every +row). ### W2 — Space-filling design: where in (`beta`, `gamma`) space is the epidemic large? @@ -286,26 +315,27 @@ parameters.beta = { range = { min = 0.05, max = 1.0 } } parameters.gamma = { range = { min = 0.02, max = 0.5 } } ``` -It writes `designs/wide/parameter_points.tsv` and one store leaf per point, as -today; `batch run` emits no quantities. **After**, the same points can be -simulated with quantities by passing the generated table as a design: +It writes `designs/wide/parameter_points.tsv` (a leading `point_id` column, +`batch.rs:1853-1859`, verified) and one store leaf per point; `batch run` emits +no quantities. After, `parameter_points.tsv` is written at round-trip precision. +To get quantities, simulate the generated table as a design: ``` -camdl simulate sir.camdl --design out/designs/wide/parameter_points.tsv \ +camdl simulate sir.camdl --sweep-file out/designs/wide/parameter_points.tsv \ --backend ode --quantities-out q ``` ``` -sweep:beta sweep:gamma value -0.43… 0.11… … +point_id sweep:beta sweep:gamma value +0 0.4318820513776011 0.1127305823481931 … … ``` -(64 rows.) `parameter_points.tsv` carries a `point_id` column -(`batch.rs:1853-1859`, verified); `--design` treats a column named `point_id` as -a row label and not a parameter, and refuses any other non-parameter column. +(64 rows; `point_id` is taken from the file, §12.1.) This **recomputes** every +point: `simulate` does not look up the `batch run` leaves, and its seeds differ +from the manifest's explicit seeds unless the same `--seed` is given (§7.3). Collisions: a scenario touching `beta` or `gamma` is refused, naming "the design -`wide`" from `batch run` and "the design file `parameter_points.tsv`" from +`wide`" from `batch run` and "the sweep file `parameter_points.tsv`" from `simulate`. ### W3 — Prior predictive and uniform exploration: what does the model predict before seeing data? @@ -317,42 +347,57 @@ camdl simulate sir.camdl --draws prior -n 500 --backend ode --quantities-out q camdl simulate sir.camdl --draws uniform -n 500 --backend ode --quantities-out q ``` -`q/quantities/peak_I.tsv` is unchanged: +`q/quantities/peak_I.tsv` gains a `band` column: ``` -n_draws q05 q25 q50 q75 q95 -500 … … … … … +band n_draws q05 q25 q50 q75 q95 +prior 500 … … … … … ``` -The manifest gains, for `--draws prior`: +Manifest `summary` for `--draws prior`: ```json -"summary": { "over": ["sample"], "measure": "prior", "n_samples": 500, - "replicates_per_sample": 1, "scenario_touches": {} } +{ + "over": ["sample"], + "measure": "prior", + "measure_source": { "kind": "model_ir" }, + "varying": ["beta", "gamma"], + "n_samples": 500, + "replicates_per_sample": 1, + "impositions": [] +} ``` -and for `--draws uniform`, `"measure": "uniform_bounds"` — quantiles under the -uniform law on the declared bounds, which no one should caption as a credible -interval. +For `--draws uniform`, `band` is `uniform_bounds` — quantiles under the uniform +law on the declared bounds, which no one should caption as a credible interval. **Per-point rows instead of a band.** Export the sample, then read it back as a design: ``` camdl simulate sir.camdl --draws uniform -n 500 --draws-out u.tsv --backend ode -camdl simulate sir.camdl --design u.tsv --backend ode --quantities-out q +camdl simulate sir.camdl --sweep-file u.tsv --backend ode --quantities-out q ``` -giving `sweep:beta sweep:gamma value`, 500 rows — the table for plotting -`peak_I` against each parameter. +giving `point_id sweep:beta sweep:gamma value`, 500 rows. This works because +`--draws-out` writes only the varying columns once gh#949 lands; a constant +column in a sweep file is refused (§12.1), so an export that still carried +constants would say so rather than silently make every parameter a coordinate. + +**Collisions.** `--scenario pinned` over the prior imposes `beta`. It runs, with +one stderr line: -**Collisions.** `--scenario pinned` over the prior shadows `beta`: it runs, with -one stderr line -(`scenario 'pinned' sets sampled parameter beta; its prior -variation is removed from this arm`) -and `"scenario_touches": {"beta": "set"}` in that arm's `summary`. -`--scenario distancing` rescales `beta`: -`"scenario_touches": {"beta": "scale"}`, no stderr line. +``` +note: scenario 'pinned' sets beta=0.3 in every prior draw; other parameters keep +their prior draws (an intervention, not the prior conditional on beta=0.3). To +reduce transmission relative to each draw, scale beta instead. +``` + +and +`"impositions": [{"param": "beta", "by": "scenario:pinned", "action": +"set"}]` +in that arm's summary. `--scenario distancing` rescales `beta`: +`{"action": "scale"}`, no stderr line. ### W4 — Posterior predictive under interventions: what does distancing do, given what the fit learned? @@ -363,27 +408,23 @@ camdl simulate sir.camdl --fit results/fits/sir-8a3f12b4 --draws posterior \ --scenario baseline,distancing --backend ode --quantities-out q ``` -`q/quantities/peak_I.tsv` is unchanged: - ``` -scenario n_draws q05 q25 q50 q75 q95 -baseline 200 … … … … … -distancing 200 … … … … … +scenario band n_draws q05 q25 q50 q75 q95 +baseline posterior 200 … … … … … +distancing posterior 200 … … … … … ``` -Each arm's manifest entry gains a `summary`; for `distancing`: - -```json -"summary": { "over": ["sample"], "measure": "posterior", "n_samples": 200, - "replicates_per_sample": 1, "scenario_touches": {"beta": "scale"} } -``` +Summary for `distancing`: `"measure": "posterior"`, +`"measure_source": +{"kind": "fit", "handle": "@sir-fit", "run_id": "…", "method": "pgas"}`, +`"varying": ["beta", "gamma"]` (the fit's estimated parameters), +`"impositions": [{"param": "beta", "by": "scenario:distancing", "action": +"scale"}]`. +With `--scenario pinned`, the W3 note is printed with "posterior draw" / "fitted +draws". The draws are paired across arms — draw _i_ uses one parameter vector +and one process seed in both (§6.3). -`--scenario pinned` shadows `beta` in its arm (`{"beta": "set"}`, one stderr -line); `gamma` still varies across the 200 draws, so `over` stays `["sample"]`. -The draws are paired across arms — draw _i_ uses one parameter vector and one -process seed in both (§6.3). - -### W5 — Exporting a posterior and reusing it +### W5 — Exporting a posterior and reusing it, with its provenance **Today**: @@ -393,52 +434,67 @@ camdl simulate sir.camdl --fit results/fits/sir-8a3f12b4 --draws posterior \ camdl simulate sir.camdl --draws post.tsv --scenario distancing ... ``` -The second command is refused today, because `distancing` scales the file's -`beta` column and a draws file is a user-authored coordinate (§4.4). +The second command is refused, because `distancing` scales the file's `beta` +column and a draws file is a user-authored coordinate (§4.4). -**After**, `--draws-out` ends with a round-trip hint: +**After**, `--draws-out` writes `post.tsv` and a provenance sidecar +`post.tsv.json` (§12.4), and prints the round-trip hint: ``` -draws.tsv: wrote 200 draws to post.tsv (reuse as a sample with --draws-file post.tsv) +draws.tsv: wrote 200 draws to post.tsv (+ post.tsv.json); reuse as a sample with --draws-file post.tsv ``` -and - ``` camdl simulate sir.camdl --draws-file post.tsv --scenario distancing \ --backend ode --quantities-out q ``` -runs: the file is a sample (`Measure::Asserted`), so the scenario is a -counterfactual over it. Output as in W4 with -`"measure": "asserted", "source": "post.tsv"` in the `summary`. The same file -passed as `--design post.tsv --scenario distancing` is refused, naming "the -design file `post.tsv`". +runs. The sidecar carries the measure through: `band` is `posterior`, not +`asserted`, and `measure_source` names the original fit. Without a sidecar the +same file reads as `band = asserted`, +`measure_source: {"kind": "file", +"sha256": "…"}`. Passing `post.tsv` as +`--sweep-file` with the same scenario is refused, naming "the sweep file +`post.tsv`"; without a scenario it is refused anyway if the file carries +`chain`/`draw` columns, with a hint to use `--draws-file` (§12.1). ### W6 — `fit predict` over a sweep, the posterior, and scenarios -**Today and after**, the command and its output bytes are identical (Stage 4 is -gated on byte identity): - ``` camdl fit predict @sir-fit --sweep gamma=0.08,0.12 --scenario distancing ``` -`/quantities/peak_I.tsv`: +**Today**, `/quantities/peak_I.tsv` is design-major — sweep point outer, +scenario inner (`predict.rs:2188` then `:2295`), which `StackedQuantities` +preserves because it does not sort: ``` scenario sweep:gamma n_draws rhat ess q05 q25 q50 q75 q95 fitted 0.08 200 … … … … … … … -fitted 0.12 200 … … … … … … … distancing 0.08 200 … … … … … … … +fitted 0.12 200 … … … … … … … distancing 0.12 200 … … … … … … … ``` -What changes is internal: the sweep is the job's design axis and the posterior -its sample axis, so one `run_job` runs every (scenario, sweep point, draw), and -the grouping key comes from the cell. The manifest gains `summary` with -`"measure": "posterior"`. `--sweep beta=… --scenario distancing` is refused by -the shared guard, naming "the sweep" (today predict's own call does this). +**After the output-schema commit** (Stage 4), the header gains `point_id` and +`band`, and swept values print exactly: + +``` +scenario point_id sweep:gamma band n_draws rhat ess q05 … +fitted 0 0.08 posterior 200 … … … … +distancing 0 0.08 posterior 200 … … … … +fitted 1 0.12 posterior 200 … … … … +distancing 1 0.12 posterior 200 … … … … +``` + +**After Stage 7** (the sweep moves onto the grid) the bytes are identical to the +Stage 4 output: rows stay design-major by explicit rendering order. The summary +records the design's imposition on the posterior: `"design_shadows": ["gamma"]`, +and `measure` reads `"posterior"` with +`"measure_note": "posterior of the other parameters with gamma imposed by the +design"`; +one stderr note says the same. `--sweep beta=… --scenario distancing` is refused +before any artifact is written (§14, Stage 7), naming "the sweep". ### W7 — Contrasts: how many infections does an intervention avert? @@ -446,14 +502,14 @@ A contrast is a paired reduction: `fit predict` replays both arms per forkable posterior draw under one seed, `derive_chain_seed(seed, draw_pos)` (`contrasts.rs:447`, verified), differences them, then bands the differences into `contrasts/.tsv` with header `q05 q25 q50 q75 q95 mean n_used` for a -scalar (`contrasts.rs:1240`). A contrast needs an arm that toggles an -intervention; a parameter-only scenario such as `distancing` is skipped with a -note (`contrasts.rs:32`, gh#327). So W7 adds an intervention `closure` and a -scenario `close { enable = [closure] }`, and declares +scalar (`contrasts.rs:1240`). Each arm's draw row enters the resolver at tier +3.5 (`contrasts.rs:643`). A contrast needs an arm that toggles an intervention; +a parameter-only scenario such as `distancing` is skipped with a note +(`contrasts.rs:32`, gh#327). So W7 adds an intervention `closure` and a scenario +`close { enable = [closure] }`, and declares `averted = fitted.quantities.final_R - close.quantities.final_R`. -**Today and after**, `camdl fit predict @sir-fit` writes -`contrasts/averted.tsv`: +`camdl fit predict @sir-fit` writes `contrasts/averted.tsv`: ``` q05 q25 q50 q75 q95 mean n_used @@ -465,38 +521,47 @@ coordinate — and the rule is: a paired reduction across a partition axis is valid iff both sides share one sample axis (§6.3). Within a job this always holds across scenarios. -**With a design axis present**, pairing would be within each design point -(scenario arms matched on sample coordinate, one contrast band per -`sweep:` value). That is not delivered here, because contrasts fork each -draw from its _smoothed_ state, inferred under the fitted parameters; a sweep -point replaces a parameter, so no smoothed state exists for it — the same reason -`conditioned_here` refuses a conditioned read for any swept cell -(`predict.rs:934-943`, verified). Contrasts therefore stay sweep-agnostic, as -today (`emit_contrasts` receives no sweep, `predict.rs:2731-2739`, verified): -emitted once, for the un-swept model. A free-forward contrast that pairs within -design points is a separate feature to file as its own issue. +**With `--sweep` present**, pairing would be within each design point. That is +not delivered, because contrasts fork each draw from its _smoothed_ state, +inferred under the fitted parameters; a sweep point replaces a parameter, so no +smoothed state exists for it — the same reason `conditioned_here` refuses a +conditioned read for any swept cell (`predict.rs:934-943`, verified). +`emit_contrasts` receives no sweep (`predict.rs:2731-2739`, verified), so +contrasts are emitted once, for the un-swept model. After, `fit predict` says +so: a stderr note +(`contrasts are computed for the fitted model only; --sweep is +not applied to them`) +and `"contrasts": {"sweep": "not applied (fitted model +only)"}` in +`report.json`. Contrasts write no manifest of their own (`contrasts.rs` writes +only TSVs; verified by search), so `report.json` is the record. Paired contrasts +within design points, and a per-cell table that makes paired reductions possible +from `simulate`, are follow-up F2. ### W8 — Process noise only: how variable is one parameter set's epidemic? -**Today and after**: - ``` camdl simulate sir.camdl --params base.toml --replicates 50 \ --backend chain_binomial --quantities-out q ``` ``` -n_draws q05 q25 q50 q75 q95 -50 … … … … … +band n_draws q05 q25 q50 q75 q95 +process 50 … … … … … ``` -`"summary": {"over": ["replicate"], "measure": "process", "n_samples": 0, -"replicates_per_sample": 50, "scenario_touches": {}}`. -`--seeds 1,2,3` is the same with explicit seeds. Under `--backend ode` -replicates are identical (the ODE solver draws no randomness), so `over` omits -`replicate` and `measure` is absent: the band has zero width and the manifest -says it reduces nothing. Scenarios make groups as usual; nothing collides, -because there are no parameter coordinates. +`"summary": {"over": ["replicate"], "measure": "process", "n_samples": null, +"replicates_per_sample": 50, …}`. +`--seeds 1,2,3` is the same with explicit seeds. + +Under `--backend ode` the solver draws no randomness, so replicates repeat one +trajectory. A pure-state quantity (`peak_I`, `final_R`) then renders in point +mode — one `value` — with `"over": []`, rather than `n_draws = 50` and +`q05 = q95`. A quantity that reads `observations.` still varies across +replicates, because each replicate's observation draw uses its own seed +(`main.rs:2447-2460`), and stays banded with `"over": ["replicate"]`. Mode is +therefore decided per quantity (§10). Nothing collides: there are no parameter +coordinates. ### W9 — A hand-authored grid passed the old way @@ -507,20 +572,24 @@ the pooled band of §4.1. ``` error: `--draws grid.tsv` no longer accepts a file: say which axis its rows are. - --design grid.tsv the rows are conditions to compare (a grid, a + --sweep-file grid.tsv the rows are conditions to compare (a grid, a hand-picked set): one output row per point, reported - in `sweep:` columns + in `point_id` and `sweep:` columns --draws-file grid.tsv the rows are a sample from a distribution you stand behind (e.g. exported posterior draws): pooled into one quantile band `--draws` takes only `posterior`, `prior` or `uniform`. ``` -### W10 — A `batch run` sweep that a scenario would override +A value that is neither a keyword nor an existing path is a different error: +`--draws prio` → +`error: unknown draws source 'prio'; expected posterior, prior +or uniform (did you mean 'prior'?)`. + +### W10 — A sweep that a scenario would override **Today** (landed with gh#572), a manifest sweeping `beta` under `pinned` is -refused by `batch run`, `batch run --dry-run` and `batch status`, worded by -`params_resolver.rs:842-940`: +refused by `batch run`, `batch run --dry-run` and `batch status`: ``` error: parameter `beta` is controlled by both the sweep and scenario `pinned` @@ -530,35 +599,99 @@ The scenario would override every sweep value, so this sweep would not vary `bet Fix: remove `beta` from the sweep, or use a scenario that does not touch it. ``` -(Layout inferred from the formatter; the column width is -`max(len(label), len("scenario:")) + 2`.) Under `distancing` the middle lines -read `scale beta × 0.6` and "would rescale every sweep point". **After**, the -same message names the design's origin: "the sweep" for `[sweep]` and `--sweep`, -"the design `wide`" for `[design.wide]`, "the design file `grid.tsv`" for -`--design grid.tsv`. The draws-file wording is deleted: a sample never collides -(W3–W5). +(Layout inferred from the formatter, `params_resolver.rs:842-940`; the column +width is `max(len(label), len("scenario:")) + 2`.) **After**, the message names +the design's origin — "the sweep" for `[sweep]` and `--sweep`, "the design +`wide`" for `[design.wide]`, "the sweep file `grid.tsv`" for `--sweep-file` — +and the `scale` case names the reparameterization route: + +``` +error: parameter `beta` is controlled by both the sweep and scenario `distancing` + sweep: beta = [0.15, 0.3, 0.6] + scenario: scale beta × 0.6 +The scenario would rescale every sweep point of `beta`; composing a sweep with a +scenario that scales the same parameter is not supported. +Fix: factor the reduction into its own parameter (e.g. `contact_mult`, with +`beta * contact_mult` in the rate) and have the scenario scale that, or remove +`beta` from the sweep. +``` + +The draws-file wording is deleted: a sample never collides (W3–W5). Composition +of `scale` with a sweep is follow-up F1. ### W11 — `--design-from`: synthetic datasets on a fit's observation design `--design-from fit.toml` writes a dataset — one file per observation stream on the fit's own observed rows — at each parameter vector; with `--draws` it writes one `ds_NN/` per row (`args/mod.rs:470-489`; `simulate_on_bound_design`, -`main.rs:3248`). It bypasses the engine, and it ignores `--scenario` entirely -(gh#948, a separate defect). +`main.rs:3248`, which builds its own `SimRun`s and seeds at +`main.rs:3275-3276`). It bypasses the engine, and it ignores `--scenario` +entirely (gh#948, a separate defect). **After**, it accepts every parameter source — -`--draws -posterior|prior|uniform`, `--draws-file`, `--design`, `--sweep` — and -still writes one dataset per parameter point, because it never reduces. The axis -decides two things only: the directory label (`ds_NN` over sample index for a -sample; `ds_NN` with the design coordinates recorded for a design) and a -`design_from.json` beside the datasets recording the kind and measure. That -record matters downstream: a simulation-based calibration pools rank statistics -across datasets and is valid only when the truths were a sample from the prior -(`--draws prior`); a recovery study at chosen truths (`--design truths.tsv`) -must not be pooled that way. A former `--draws truths.tsv` user gets the W9 -refusal and chooses. Once gh#948 routes `--scenario` through, the §8 guard -applies to `--design-from` unchanged. +`--draws posterior|prior|uniform`, `--draws-file`, `--sweep-file`, `--sweep` — +and still writes one dataset per parameter point, because it never reduces. With +`--draws prior`, the priors and the `[fixed]` block come from the design +`fit.toml` itself; a `--fit` naming a different file is refused. Beside the +datasets it writes `truths.tsv` — one row per dataset: `dataset`, `point_id` +(design) or `sample_idx` (sample), then every parameter at round-trip precision +— and `design_from.json`: + +| Field | Type | Meaning | +| ---------------- | -------------------- | -------------------------------------------------------------------------- | +| `schema` | string | `"camdl.design_from/v1"` | +| `axis` | `"design"\|"sample"` | how the parameter points were read | +| `measure` | string or null | `posterior\|prior\|uniform_bounds\|asserted` for a sample; null for design | +| `measure_source` | object or null | as in §9.3; for `prior`, `{"kind": "fit_toml", "path_sha256": "…"}` | +| `n_datasets` | integer | number of `ds_NN/` directories | +| `truths` | string | `"truths.tsv"` | + +The record matters downstream: a simulation-based calibration pools rank +statistics across datasets and is valid only when the truths were a sample from +the prior the fit uses (`axis: sample`, `measure: prior`); a recovery study at +chosen truths (`--sweep-file truths.tsv`) must not be pooled that way. Once +gh#948 routes `--scenario` through, the §8 guard applies unchanged. + +### W12 — `--param` on a swept parameter + +``` +camdl simulate sir.camdl --sweep beta=0.15,0.3,0.6 --param beta=0.5 ... +``` + +**Today** this cannot be written in `simulate` (no `--sweep`); with a draws file +the analogue runs, because `--param` sits at tier 5 above the draw/sweep tier +(`util.rs:3244-3253`) and wins silently: three identical runs labelled with +three values. **After**, `--param` and expanded `--param-vec` entries are part +of the collision footprint (§8): on a design name they are refused — + +``` +error: parameter `beta` is controlled by both the sweep and --param + sweep: beta = [0.15, 0.3, 0.6] + --param: beta = 0.5 +--param would override every sweep value, so this sweep would not vary `beta`. +Fix: remove `beta` from the sweep, or drop --param beta. +``` + +— and on a varying sampled column they are recorded as an imposition +(`"by": "--param"`) with the W3 stderr note. + +### W13 — A draws file with a provenance sidecar, round trip + +``` +camdl simulate sir.camdl --fit results/fits/sir-8a3f12b4 --draws posterior \ + -n 1000 --draws-out post.tsv +camdl simulate sir.camdl --draws-file post.tsv -n 200 --quantities-out q +``` + +The first command writes `post.tsv` (varying columns only) and `post.tsv.json`. +The second reads both: `-n 200` subsamples the file strided across all rows +(today `-n` is ignored for a file, so a raw 60k-row `draws.tsv` replays every +row — the gh#630 hazard); `band` is `posterior`; the summary's `n_samples` is +200 and `measure_source` names the original fit and records +`"subsample": {"from": 1000, "stride": 5}`. If `sir.camdl` has changed since the +export, the IR hash in the sidecar differs and a warning says so; parameters the +file does not carry are listed in `"from_outside_file": [...]` with where their +values came from (`--params`, `--fit` `[fixed]`, model default). ## 6. The rule: design coordinates condition, samples are marginalized @@ -571,44 +704,47 @@ condition on the point or marginalize over it. The asymmetry that makes this safe: **any point set may be read as a design; only a point set with a measure may be read as a sample.** A sweep grid has no measure, so it can never be a sample. A generated draw has a measure by construction. A file has one only if -the user asserts it, which is what `--draws-file` means. - -| Source | Axis | Measure | -| ------------------------------------------- | ------ | ----------------------- | -| `--draws posterior`, `fit predict` | sample | the fit's posterior | -| `--draws prior` | sample | prior (IR or fit.toml) | -| `--draws uniform` | sample | uniform on declared box | -| `--draws-file FILE` | sample | asserted by the user | -| `--sweep`, `[sweep]`, `fit predict --sweep` | design | — | -| `--design FILE` | design | — | -| `[design.NAME]` | design | — | +the user asserts it, which is what `--draws-file` means (or its sidecar +records). + +| Source | Axis | Measure | +| ------------------------------------------- | ------ | ------------------------------------------ | +| `--draws posterior`, `fit predict` | sample | the fit's posterior | +| `--draws prior` | sample | prior (model IR or fit.toml) | +| `--draws uniform` | sample | uniform on the declared bounds | +| `--draws-file FILE` | sample | from `FILE.json` if present, else asserted | +| `--sweep`, `[sweep]`, `fit predict --sweep` | design | — | +| `--sweep-file FILE` | design | — | +| `[design.NAME]` | design | — | `--draws uniform` is an iid sample, not a space-filling design: `generate_uniform_draws` draws `lo + (hi − lo)·U` independently per parameter per row (`main.rs:3860-3880`, verified), holding unbounded parameters at their default. The run spec calls it "space-filling exploration" -(`docs/camdl-run-spec.md:1145-1148`); Stage 3 corrects that sentence. The actual +(`docs/camdl-run-spec.md:1145-1148`); Stage 5 corrects that sentence. The actual space-filling designs (`[design.NAME]`, `method = "sobol" | "lhs" | "random"`, -`batch.rs:98-107`) already run as `ParamSource::Sweep` (`batch.rs:1839`) — which -is reason 3 in running code: one iid uniform design is a partition in -`batch run` and a band in `simulate`. +`batch.rs:98-107`) already run as `ParamSource::Sweep` (`batch.rs:1839`) — +reason 3 in running code. ### 6.2 What each kind implies for the three decisions -| Decision | Design coordinate | Sample | -| --------------------------------------- | -------------------------------------------------------- | ---------------------------------------------------------------- | -| A scenario sets or scales the parameter | Contradiction — refused, naming the design's origin | Counterfactual — allowed; recorded in `summary.scenario_touches` | -| Labels and provenance | A `sweep:` column and the manifest `sweep` object | Not per row; the manifest names the measure | -| Marginal reduction (quantile, mean) | Never crosses it | Crosses it | -| Paired reduction | Crosses it, matched on the band coordinate | Supplies the matching coordinate | +| Decision | Design coordinate | Varying sampled column | +| -------------------------------------- | ------------------------------------------------------ | ----------------------------------------------------------------------------- | +| A scenario or `--param` sets/scales it | Contradiction — refused, naming the design's origin | Imposition — allowed; recorded in `summary.impositions`, stderr note on `set` | +| A design coordinate sets it | — | Imposition — allowed; recorded in `summary.design_shadows`, stderr note | +| Labels and provenance | `point_id` + `sweep:` columns, manifest `sweep` | Not per row; `band` column and manifest name the measure | +| Marginal reduction (quantile, mean) | Never crosses it | Crosses it | +| Paired reduction | Crosses it, matched on the band coordinate | Supplies the matching coordinate | + +A constant (non-varying) column of a sample row is neither: a scenario or +`--param` over it changes a constant, as over `--params`, and is not recorded. The collision row is the landed gh#572 policy on every input it covers: sweeps, -design blocks and design files are refused, generated draws allowed. The one -input whose treatment changes is an exported sample read back with -`--draws-file`, which is now allowed under a scenario (W5) — the file carries a -declared measure, so a scenario over it is a counterfactual, exactly as over the -generated draws it came from. +design blocks and sweep files are refused, generated draws allowed. The one +input whose treatment changes is a file read with `--draws-file`, now allowed +under a scenario (W5) — the file carries a declared measure, so a scenario over +it is an intervention, exactly as over the generated draws it came from. ### 6.3 Marginal reductions stay within a group; paired reductions cross groups @@ -621,8 +757,9 @@ A paired reduction across a partition axis is well-defined iff the cells on both sides share one `SampleAxis` (same rows, same measure): the band coordinate then denotes the same parameter vector on both sides. Pairing across scenarios always satisfies this (a job has one sample axis, and the process seed excludes the -scenario, `engine.rs:11-23`). Whether pairing also enjoys the variance reduction -of common random numbers depends on the seed rule (§7.3), not on validity. +scenario, `engine.rs:11-23`). Pairing across design points satisfies it when the +design is crossed with a sample axis; after this proposal it also enjoys common +random numbers there, because the design index no longer enters the seed (§7.3). ### 6.4 Out of scope @@ -632,26 +769,31 @@ quantile path and `batch run` emits no quantities, so design blocks are design axes; a weighted-sample reading waits for a consumer. **`fit run --sweep`** fits once per point — not a simulation grid — and is -untouched. +untouched. `fit`, `profile`, `eval`, `pfilter` and `survey` resolve with +`point_overrides: &[]` (`fit/runner.rs:319`, `profile.rs:554`, `eval.rs:113`, +`pfilter.rs:218`, `survey.rs:757`, `:969`, verified) and are unaffected. ## 7. Types -### 7.1 The plan: two axes and a replicate count +### 7.1 The plan: a design axis and a sample axis `ParamPlan` replaces `ParamSource` in `SimulateJob.source` (`sim_job.rs:70`). Each axis is a sum type whose unit case is its own variant, so an axis with no -points but an origin, or a sample without a measure, cannot be written down. +points but an origin, or a sample without a measure, cannot be written down. The +replicate count is not part of the plan: it is derived from `Seeds` (explicit +seed-list length, else `--replicates`), as `effective_replicates` already does +(`engine.rs:325-334`); storing it twice is how `batch run` came to pass +`replicates: 1` beside an explicit seed list. ```rust // sim_job.rs /// The parameter-point structure of a job: a design axis crossed with a -/// sample axis, each cell replicated `replicates` times. +/// sample axis. #[derive(Debug, Clone)] pub struct ParamPlan { design: DesignAxis, sample: SampleAxis, - replicates: usize, } /// Labelled conditions the user chose. A partition axis. @@ -668,6 +810,11 @@ pub enum DesignAxis { #[derive(Debug, Clone, PartialEq)] pub struct DesignPoint { + /// The point's stable id: the file's `point_id` column if present, else + /// the 0-based position in the axis. + point_id: u64, + /// Optional string label carried from a `--sweep-file` `label` column. + label: Option, /// Requested values, aligned to `names`. values: Vec, } @@ -675,25 +822,30 @@ pub struct DesignPoint { /// Where a design came from — the noun the collision diagnostic uses. #[derive(Debug, Clone)] pub enum DesignOrigin { - Sweep, // --sweep, [sweep], fit predict --sweep + Sweep, // --sweep, [sweep], fit predict --sweep: "the sweep" Design { method: DesignMethod }, // [design.NAME]: "the design `NAME`" - File { path: PathBuf }, // --design FILE + File { path: PathBuf }, // --sweep-file FILE: "the sweep file `FILE`" } /// Exchangeable draws from a stated measure. A band axis. #[derive(Debug, Clone)] pub enum SampleAxis { Unit, - Draws { rows: Vec>, measure: Measure }, + Draws { + rows: Vec>, + measure: Measure, + /// The parameters the measure varies (§2), computed at construction. + varying: Vec, + }, } /// What the sample is a sample of. #[derive(Debug, Clone, PartialEq)] pub enum Measure { - Posterior { fit: String, method: String }, // fit handle + stage label - Prior { source: PriorSource }, // model IR or fit.toml + Posterior { fit: FitRef, method: String }, // fit handle + run id + stage + Prior { source: PriorSource }, // model IR or fit.toml (+ content hash) UniformBounds, // lo + (hi-lo)·U on declared bounds - Asserted { path: PathBuf }, // --draws-file FILE + Asserted { path: PathBuf, sha256: String }, // --draws-file FILE, no sidecar } ``` @@ -703,34 +855,127 @@ validating its invariant: ```rust impl ParamPlan { - pub fn point(replicates: usize) -> Self; + pub fn point() -> Self; /// Refuses a design name the model does not declare. - pub fn new(design: DesignAxis, sample: SampleAxis, replicates: usize, model: &ir::Model) + pub fn new(design: DesignAxis, sample: SampleAxis, model: &ir::Model) -> Result; } impl DesignAxis { - /// From the CLI grammar shared with `fit predict` (§7.5). + /// From the CLI grammar shared with `fit predict` (§7.5): Cartesian product, + /// sorted by parameter name; a repeated name is refused. pub fn sweep(specs: &[crate::args::types::SweepSpec]) -> Result; - /// From already-expanded rows: `[sweep]`, `[design.NAME]`, `--design FILE`. - /// Refuses ragged rows and duplicate rows. - pub fn from_rows(rows: Vec>, origin: DesignOrigin) + /// From already-expanded rows: `[sweep]`, `[design.NAME]`, `--sweep-file`. + /// Refuses an empty row set and ragged rows. Duplicate rows are refused + /// for `File` only (D-E, §12.1). + pub fn from_rows(rows: Vec, origin: DesignOrigin) -> Result; } impl SampleAxis { - pub fn draws(rows: Vec>, measure: Measure) -> Self; + /// Refuses an empty row set. `varying` is supplied by the source (the fit's + /// estimated set, the parameters with a prior, the bounded parameters, the + /// sidecar's list) or computed as the non-constant columns of a file. + pub fn draws(rows: Vec>, measure: Measure, varying: Vec) + -> Result; } ``` -Duplicate design rows are refused because two groups with identical coordinates -are indistinguishable in a tidy output; the same condition twice is -`--replicates`. +Duplicate points in a generated grid (`--sweep beta=0.1,0.1`, a `[sweep]` list +with a repeat, `fit predict --sweep`) are accepted as today: they are two groups +whose coordinates coincide, and under effective-value identity (§13) their cells +resolve to one store leaf, simulated once and reported twice. -A design name may also be a sample column: `fit predict --sweep gamma` crosses a -`gamma` design with posterior rows that carry `gamma`, and the design value wins -(today's row overwrite, `predict.rs:2235-2237`). §7.4 makes that a resolver -tier. +A design name may also be a varying sample column: `fit predict --sweep gamma` +crosses a `gamma` design with posterior rows that vary `gamma`. The design value +wins (today's row overwrite, `predict.rs:2235-2237`) and is recorded as a design +shadow (§6.2). §7.4 makes the precedence a resolver tier. -### 7.2 The cell: coordinates replace `point_idx` and `point_overrides` +### 7.2 Varying columns are computed once, at the boundary + +Today every generated row carries every parameter: `uniform` fills unbounded +parameters with their default (`main.rs:3874-3890`); `prior` fills parameters +without a prior with their default or a scenario's value (`main.rs:4131-4143`, +`:4190-4204`); a posterior `draws.tsv` carries the fit's `[fixed]` columns. +gh#949 stops generated rows from carrying what the measure does not sample. +`varying` is the list this proposal reads, wherever it matters: the collision +recording (§8), the stderr note, `summary.varying`, and `summary.over` (a +scenario that sets every varying column leaves nothing to pool over the sample). + +| Measure | `varying` | +| --------------- | ------------------------------------------------------------------- | +| `Posterior` | the fit's estimated parameters, read from the fit config/metadata | +| `Prior` | parameters with a prior in the prior source | +| `UniformBounds` | parameters with declared bounds (the ones actually drawn) | +| `Asserted` | the sidecar's list if present, else the file's non-constant columns | + +### 7.3 Design coordinates never enter the process seed + +```rust +impl CellCoords { + fn seed_index(&self) -> usize { + match self.sample { + SampleCoord::Draw { idx } => idx, + SampleCoord::Unit => 0, + } + } +} +``` + +`process_seed_for` (`engine.rs:52-66`) receives `seed_index` where it receives +`point_idx` today. Design points therefore share process seeds (common random +numbers), matching `batch run` (explicit seeds, `batch.rs:596`, where the index +is unused, `engine.rs:59-60`) and `fit predict` (the seed follows the draw +index). Consequences, job by job: + +- `--draws posterior|prior|uniform` and `--draws-file`: sample index = today's + `point_idx`; every trajectory is unchanged. `--draws-file FILE` has exactly + the seeds of today's `--draws FILE`. +- `--sweep` and `--sweep-file` are new; they have no runs to preserve. Every + design point uses `seed_index = 0`, so inserting or removing a point leaves + the other points' trajectories unchanged. +- The same file passed as `--sweep-file` and as `--draws-file` runs at different + seeds (0 for every row vs the row index), so the two do not share store leaves + beyond row 0; their ensembles are keyed apart as well (§7.6). +- `fit predict --sweep` after Stage 7: sample index = draw index, as inside + today's per-sweep-point jobs. The `total_runs == 1` branch (`engine.rs:61-62`) + no longer fires on the combined job, but `mix_cell_seed(base, 0, 0) == base` + (`util.rs:35-37`; asserted at `engine.rs:520`), so the seed is unchanged. + +`seed_index` also replaces `point_idx` at the per-draw lookups that follow the +sample: the `--init-state fit` row (`engine.rs:452-455`), +`ConditionedSource::per_draw` (`predict.rs:1187`), `chain_of_point` +(`predict.rs:1262`), and `RunEntry.draw_idx` (`batch.rs:1498`, `:1707`), which +feeds the `SimEnsemble` grid digest (`sim_ensemble_cas.rs:19-24`, `:120-133`, +`:192`; `main.rs:2680`). + +### 7.4 Resolver tier 3.5 splits in two, design over sample + +Tier 3.5 (`params_resolver.rs:1095-1118`) becomes 3.5a (sample row) then 3.5b +(design point), and `ValueSource::SweepPoint` (`params_resolver.rs:193-196`) +splits into `ValueSource::Sample` and `ValueSource::DesignPoint`. +`SimRun.point_overrides` (`engine.rs:469`) splits into `sample_overrides` and +`design_overrides`. Resolved values are unchanged for every current job: the +only job with both axes is `fit predict --sweep`, whose design value already +overwrote the sample value. + +The tag is not persisted anywhere a user reads: `run.json.inputs` has no +provenance tag (`resolve.rs:238-245`), and every fitting and filtering path +resolves with `point_overrides: &[]` (§6.4), so `parameters_provenance` never +holds `sweep_point`. The tag surfaces only in error text: `UnknownParameter` +prints `from SweepPoint` via `Debug` (`params_resolver.rs:375-381`), and +`NonFiniteValue` suggests `--fixed` (`:393-400`), which `simulate` users spell +`--param`. Stage 8 rewrites both: +`column`betta`of`post.tsv`is not a +parameter of sir.camdl`, and a fix hint +naming the flag the command actually has. + +`SimRun` has construction sites beyond `build_cell_sim_run`: `main.rs:1236` (the +base run), `main.rs:3275` (`--design-from`), `util.rs:3166`, +`util.rs:3259-3285`, `util.rs:4427`, and the contrasts arm resolver passes draw +rows at tier 3.5 (`contrasts.rs:643`). Each is classified in Stage 2; the +exhaustive destructure at `batch.rs:2888` makes the compiler list any that is +missed. + +### 7.5 The cell: coordinates replace `point_idx` and `point_overrides` ```rust // engine.rs @@ -764,69 +1009,37 @@ pub struct PartitionKey { scenario: String, design: Option } impl CellCoords { pub fn partition_key(&self) -> PartitionKey; - pub fn seed_index(&self) -> usize; // §7.3 } -``` -`CellSpec`'s fields become private to `engine`, read through accessors, and -`plan_grid` (`engine.rs:148-193`) is the single constructor. The one non-test -constructor outside it is `CasSink::predict_cells` (`batch.rs:1272-1307`, -verified), which re-implements the grid loop for dry-run cache classification; -it calls `plan_grid` instead (Stage 0). The test fixtures (`batch.rs:2771`, -`:2849`) use a `#[cfg(test)]` constructor in `engine`. `plan_grid` iterates -`scenario → design point → sample → replicate`; with either axis `Unit` this is -today's `scenario → point → rep`. - -### 7.3 The seed index preserves every current trajectory - -```rust -fn seed_index(&self) -> usize { - match (&self.sample, &self.design) { - (SampleCoord::Draw { idx }, _) => *idx, - (SampleCoord::Unit, DesignCoord::Point { idx, .. }) => *idx, - (SampleCoord::Unit, DesignCoord::Unit) => 0, - } +pub struct Grid { + pub n_scenarios: usize, + pub n_design: usize, // 1 for DesignAxis::Unit + pub n_samples: usize, // 1 for SampleAxis::Unit + pub n_replicates: usize, + pub total_runs: usize, + pub parallel: usize, } ``` -`process_seed_for` (`engine.rs:52-66`) receives `seed_index` where it receives -`point_idx` today. Every existing job maps to the same number: - -- `--draws posterior|prior|uniform`: design `Unit`, sample index = today's - `point_idx`. -- `--draws-file`: the same rows at the same indices as today's `--draws FILE`. -- `--design FILE`: sample `Unit`, design index = today's `point_idx` for the - same file under `--draws`. -- `batch run`: explicit seeds (`batch.rs:596`), so `seeds[rep]`; the index is - unused (`engine.rs:59-60`). -- `fit predict --sweep` after Stage 4: sample index = draw index, as inside - today's per-sweep-point jobs. The `total_runs == 1` branch (`engine.rs:61-62`) - no longer fires on the combined job, but `mix_cell_seed(base, 0, 0) == base` - (`util.rs:35-37`; asserted at `engine.rs:520`), so the seed is unchanged. - -`seed_index` also replaces `point_idx` at the three per-draw lookups that follow -the sample: the `--init-state fit` row (`engine.rs:452-455`), -`ConditionedSource::per_draw` (`predict.rs:1187`) and `chain_of_point` -(`predict.rs:1262`). The wide-format `draw` column (`main.rs:2931`) prints -`seed_index + 1`, byte-identical. - -### 7.4 Resolver tier 3.5 splits in two, design over sample - -Tier 3.5 (`params_resolver.rs:1095-1118`) becomes 3.5a (sample row) then 3.5b -(design point), and `ValueSource::SweepPoint` (`params_resolver.rs:193-196`, -tagged `"sweep_point"` at `:209` even for a posterior draw) splits into -`ValueSource::Sample` and `ValueSource::DesignPoint`. `SimRun.point_overrides` -(`engine.rs:469`) splits into `sample_overrides` and `design_overrides`. -Resolved values are unchanged for every current job: the only job with both axes -is `fit predict --sweep`, whose design value already overwrote the sample value. - -### 7.5 Parse at the boundary: where each plan is built +`CellSpec`'s fields become private to `engine`, read through accessors, and +`plan_grid` (`engine.rs:148-193`) is the single constructor. `cli` is a binary +crate, so module privacy is enforceable; test fixtures (`batch.rs:2771`, +`:2849`) use a `#[cfg(test)]` constructor in `engine`. The one non-test +constructor outside `plan_grid` is `CasSink::predict_cells` +(`batch.rs:1272-1307`, verified), which re-implements the grid loop for dry-run +cache classification; it calls `plan_grid` instead (Stage 0). `plan_grid` +iterates `scenario → design point → sample → replicate`; with either axis `Unit` +this is today's `scenario → point → rep`. `Grid` (`engine.rs:105-111`) gains the +per-axis sizes the banner, the dry run and the combined writers need (§12.3, +§9.2). + +### 7.6 Parse at the boundary, and who reads what | Front end | Today | After | | ------------------------- | ------------------------------------------------------------------ | ------------------------------------------------------------------------------- | -| `simulate --draws …` | `main.rs:1735-1751`: `Draws { explicit_file }` or `Point` | `SampleAxis::draws(rows, Posterior \| Prior \| UniformBounds)` | -| `simulate --draws-file` | (was `--draws FILE`) | `SampleAxis::draws(rows, Asserted { path })` | -| `simulate --design` | — | `DesignAxis::from_rows(rows, File { path })` | +| `simulate --draws …` | `main.rs:1735-1751`: `Draws { explicit_file }` or `Point` | `SampleAxis::draws(rows, Posterior \| Prior \| UniformBounds, varying)` | +| `simulate --draws-file` | (was `--draws FILE`) | `SampleAxis::draws(rows, sidecar measure \| Asserted, varying)` | +| `simulate --sweep-file` | — | `DesignAxis::from_rows(rows, File { path })` (reader §12.1) | | `simulate --sweep` | — | `DesignAxis::sweep(&specs)` | | `batch run [sweep]` | `batch.rs:629-644` (`sweep_source`): `Sweep` or `Point` | `DesignAxis::from_rows(points, Sweep)` | | `batch run [design.NAME]` | `batch.rs:1839`: `Sweep` | `DesignAxis::from_rows(points, Design { method })` | @@ -837,114 +1050,203 @@ is `fit predict --sweep`, whose design value already overwrote the sample value. predict` and `profile`: `args::types::SweepSpec` (`args/types.rs:255`, `V1,V2,… | lin(min,max,n) | log10(min,max,n)`). Predict's expansion, -`expand_predict_sweep` (`predict.rs:1359`), moves into `DesignAxis::sweep` so -both verbs expand through one function. The batch TOML sweep grammar -(`batch.rs: -143`, `linspace`/`logspace`/`range`) is a different surface syntax -and stays; its expansion (`expand_sweep`, `batch.rs:293`) feeds -`DesignAxis::from_rows`. - -Flag relations in `simulate`: `--draws`, `--draws-file` are mutually exclusive -(one sample axis); `--sweep`, `--design` are mutually exclusive (one design -axis); a sample flag and a design flag compose (design × sample, as in -`fit -predict`). `--draws` accepts only `posterior`, `prior`, `uniform`; any -other value is the W9 refusal. The `explicit_file` field (`sim_job.rs:182-191`) -is deleted. - -### 7.6 Consumers read the coordinates, never the source - -| Consumer | Reads | Replaces | -| ------------------------------------------------ | ------------------------------------ | ------------------------------------------------------------------------------------ | -| Collision guard (§8) | `ParamPlan` design names and origin | `ParamSource` match at `engine.rs:367-375`; predict's call at `predict.rs:1623-1649` | -| `SimQuantities::push_cell` (`main.rs:2464`) | `cell.spec.coords().partition_key()` | `cell.spec.scenario.name()` | -| `PredictiveSink::merge_cell` (`predict.rs:1127`) | same | same, plus the enclosing `sweep_pt` loop variable | -| Quantity `Mode` (`main.rs:1931-1940`) | `ParamPlan::mode()` (§9.1) | `matches!(source, Point { replicates: 1 })` | -| `CasSink::cell_resolve` label (`batch.rs:1150`) | design names ∪ sample columns | `point_overrides.keys()` | -| Seed and per-draw lookups (§7.3) | `coords.seed_index()` | `point_idx` | +`expand_predict_sweep` (`predict.rs:1359-1380`, Cartesian, sorted by name), and +its duplicate-name and unknown-name checks (`predict.rs:1594-1621`) move into +`DesignAxis::sweep` and `ParamPlan::new`, so both verbs expand and validate +through one function. The batch TOML sweep grammar (`batch.rs:143`, +`linspace`/`logspace`/`range`) is a different surface syntax and stays; its +expansion (`expand_sweep`, `batch.rs:293`) feeds `DesignAxis::from_rows`. A +zipped (non-Cartesian) design is a `--sweep-file`. The `explicit_file` field +(`sim_job.rs:182-191`) is deleted. + +| Consumer | Reads | Replaces | +| -------------------------------------------------------------------------- | ------------------------------------------------------------------ | ------------------------------------------------------------------------------------ | +| Collision guard (§8) | plan design names/origin, varying, `fixed_cli` | `ParamSource` match at `engine.rs:367-375`; predict's call at `predict.rs:1623-1649` | +| `SimQuantities::push_cell` (`main.rs:2464`) | `cell.spec.coords().partition_key()` | `cell.spec.scenario.name()` | +| `PredictiveSink::merge_cell` (`predict.rs:1127`) | same | same, plus the enclosing `sweep_pt` loop variable | +| `FreeForwardCell`, `assemble_predictive` (`predict.rs:2340`, `:2489-2493`) | `PartitionKey` + `DesignPoint` | the `sweep_pt` loop variable | +| Quantity mode (`main.rs:1931-1940`) | per-quantity rule (§10) | `matches!(source, Point { replicates: 1 })` | +| `CasSink::cell_resolve` label (`batch.rs:1150`) | design names ∪ varying columns | `point_overrides.keys()` | +| Seed and per-draw lookups (§7.3) | `coords.seed_index()` | `point_idx` | +| `RunEntry.draw_idx` → `SimEnsemble` grid (§7.3) | sample index; axis kind folded into the grid level for design jobs | `point_idx` | +| Wide trajectory / obs writers (`main.rs:2894`, `:2931`, `:3060`, `:3094`) | `CellCoords`, `Grid` (§9.2) | `draw = point_idx + 1`, `n_draws = grid.n_points` | +| Contrasts arm resolver (`contrasts.rs:643`) | sample row at tier 3.5a | draw row at tier 3.5 | + +The `SimEnsemble` grid level folds the axis kind for design jobs so that +`--sweep-file X` and `--draws-file X` cannot produce one ensemble `run_id` over +different bytes (the two write different combined-file columns, §9.2). ## 8. Collision policy keys on the coordinate kind `check_scenario_coordinate_collision` keeps its name and its three call sites -(§4.4) but reads the plan instead of `ParamSource`: +(§4.4), plus the `--event-log` path, which calls `plan_grid` directly +(`main.rs:1978`) and so bypasses the guard today. Its signature changes from +`Result<(), String>` to ```rust pub fn check_scenario_coordinate_collision(job: &SimulateJob) - -> Result, String> -``` - -For each scenario it intersects `scenario_param_footprint` -(`params_resolver.rs:698-733`, the shared authority) with: - -- the design axis's `names` — non-empty is a hard error. `UserCoordinate` - (`params_resolver.rs:806`) becomes a view of `DesignOrigin`, so the formatter - names the origin: "the sweep", "the design `wide`", "the design file - `grid.tsv`". The `DrawsFile` variant and its wording are deleted. -- the sample axis's columns — non-empty is allowed and returned as a - `ScenarioTouch { scenario, param, action: Set | Scale }`, which `run_job` - prints (one stderr line per `set`) and the quantity accumulators copy into - `summary.scenario_touches`. - -Because `fit predict --sweep` builds a plan with a design axis after Stage 4, -its separate call is deleted and the engine's covers it. The footprint∩names -rule is exact, not a stopgap: `2026-08-11` §1.1 shows disjointness is required -for both distinctness (distinct coordinates run distinct models) and denotation -(each emitted coordinate equals the value that ran). - -## 9. Banding keys on the coordinate kind - -### 9.1 `Mode` is a function of the plan - -```rust -impl ParamPlan { - pub fn mode(&self) -> Mode { - match (&self.sample, self.replicates) { - (SampleAxis::Unit, 1) => Mode::Point, - _ => Mode::Banded, - } - } -} + -> Result, String> ``` -This equals today's predicate (`main.rs:1931-1940`) on every input that still -exists; the file that used to pool (§4.1) now arrives as `--design`, has a -`Unit` sample axis, and renders in point mode per design point. - -### 9.2 `simulate` groups by design point, and the manifest says what the band is over +The footprint of each overriding source is intersected with the plan: + +- **Sources:** each scenario's `scenario_param_footprint` + (`params_resolver.rs:698-733`), and the job's tier-5 entries — `--param` and + the expanded `--param-vec` names (`util.rs:3244-3253`, the list the resolver + applies at tier 5). +- **Against design names** — non-empty is a hard error. `UserCoordinate` + (`params_resolver.rs:806`) becomes a view of `DesignOrigin` plus the tier-5 + source, so the formatter names the origin ("the sweep", "the design `wide`", + "the sweep file `grid.tsv`") and the overrider (scenario `pinned`, or + `--param`). The `DrawsFile` variant and its wording are deleted. The `scale` + case names the reparameterization route (W10). +- **Against varying sample columns** — allowed; each hit is returned as an + `Imposition { param, by: Scenario(name) | FixedCli, action: Set | Scale }`, + which `run_job` prints (one note per `set`, W3 wording) and the quantity + accumulators copy into `summary.impositions`. +- **Design names against varying sample columns** — allowed; returned as design + shadows (§6.2), printed once, and copied into `summary.design_shadows`. + +Because `fit predict --sweep` builds a plan with a design axis after Stage 7, +the engine's guard covers it; predict nevertheless keeps an explicit pre-flight +call before its free-forward closure, because inside that closure an error is +caught and reported as "continuing with what is computable" +(`predict.rs:2377-2382`) while other outputs are still written. The +footprint∩names rule is exact, not a stopgap: `2026-08-11` §1.1 shows +disjointness is required for both distinctness and denotation. + +## 9. Output schema + +This section is normative. It applies to `simulate --quantities-out`, +`fit predict`, and the combined trajectory/observation files of `simulate`. + +### 9.1 Quantity TSV columns + +Columns appear in this order; each group is present only when its condition +holds. + +| # | Column(s) | Present when | +| -- | ------------------------------------- | --------------------------------------------------------------------------------- | +| 1 | `scenario` | `simulate`: any `--scenario` given; `fit predict`: always | +| 2 | `point_id` | the job has a design axis | +| 3 | `sweep:` … | the job has a design axis; one per design name, sorted | +| 4 | `time` | the quantity is a series | +| 5 | `` … | the quantity is stratified | +| 6 | `band` | banded mode; one of `posterior \| prior \| uniform_bounds \| asserted \| process` | +| 7 | `n_draws` | banded mode | +| 8 | `rhat`, `ess` | banded mode with a chain partition (`fit predict`) | +| 9 | `n_value`, `n_censored`, `p_censored` | banded mode, censorable scalar | +| 10 | `q05 q25 q50 q75 q95` | banded mode | +| 10 | `value` | point mode | + +Examples: point, design, no scenario — `point_id sweep:beta value`; banded, +scenario, sample — `scenario band n_draws q05 … q95`; banded, scenario, +design, sample, chains — +`scenario point_id sweep:gamma band n_draws rhat +ess q05 … q95`. + +`band` names the measure when the sample axis is reduced, else `process`. It is +constant within a file. `sweep:` values are written with Rust's shortest +round-trip `f64` formatting (`{}`), never through `quantile::fmt_value` (6 +decimals), so a coordinate joins exactly against the grid that produced it. An +indexed parameter flattens as it does elsewhere in camdl (`beta_1`, no +brackets); the manifest's `sweep` object additionally records +`{"family": "beta", "index": [1]}` for such entries. + +Readers: the colon in `sweep:beta` requires backticks in R's `readr::read_tsv` +(`` df$`sweep:beta` ``) and becomes `sweep.beta` under `read.delim`; polars and +pandas keep it verbatim. The prefix is kept for every design origin because it +is already `fit predict`'s published header. + +Rows are ordered design-major: for each design point in axis order, each +scenario in `--scenario` order, then time and strata. This is `fit predict`'s +order today (`predict.rs:2188` → `:2295`); `simulate` adopts it. + +### 9.2 Combined trajectory and observation files + +The wide writer (`main.rs:2884-2900`) writes `replicate` (the global cell +ordinal `run_idx + 1`, when `total_runs > 1`), `scenario` (when +`n_scenarios > 1`), `draw` (when `n_draws > 1`), then time and state columns; +observation rows mirror it (`main.rs:3060`, `:3094`, `:3158`). After: + +| Column | Present when | Value | +| ----------------- | ------------------------------------- | ------------------------------- | +| `replicate` | `total_runs > 1` (unchanged) | global cell ordinal (unchanged) | +| `scenario` | `n_scenarios > 1` (unchanged, gh#576) | scenario name | +| `point_id` | design axis present | the design point's id | +| `sweep:` … | design axis present | exact coordinate | +| `draw` | sample axis with more than one row | sample index + 1 | + +A design job with no sample axis writes no `draw` column; today's `--draws FILE` +output (now `--draws-file`) is byte-identical. + +### 9.3 Manifest fields + +Every entry of `quantities.json` carries: + +| Field | Type | Req. | Meaning | +| ---------------------------------------------------------------------- | ------------------- | ----------------------------- | ------------------------------------------------------------------------------------ | +| `name`, `shape`, `source`, `index_dims`, `reduce`, `unit`, `censoring` | as today | yes | unchanged (`quantity_output.rs:466-474`) | +| `mode` | `"point"\|"banded"` | yes | how this entry's file is rendered | +| `partition_columns` | array of string | yes | the grouping columns present, in order (e.g. `["scenario","point_id","sweep:beta"]`) | +| `scenario` | string | when a scenario column exists | this group's scenario (as today) | +| `sweep` | object | when a design axis exists | `{param: value}` exact; indexed params also `{family, index}` | +| `point_id` | integer | when a design axis exists | this group's design point | +| `summary` | object | yes | below | + +`summary`: + +| Field | Type | Req. | Meaning | +| ----------------------- | -------------------------------- | ------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `over` | array of `"sample"\|"replicate"` | yes | band axes reduced in this group; `[]` in point mode | +| `measure` | string | when `over` is non-empty | `posterior \| prior \| uniform_bounds \| asserted \| process` | +| `measure_note` | string | no | e.g. "posterior of the other parameters with gamma imposed by the design" | +| `measure_source` | object | when `measure` is not `process` | `{"kind":"fit","handle","run_id","method"}`, `{"kind":"model_ir","ir_hash"}`, `{"kind":"fit_toml","path_sha256"}`, `{"kind":"file","sha256"}`, plus `subsample` when strided | +| `varying` | array of string | when a sample axis exists | §7.2 | +| `n_samples` | integer or null | yes | rows on the sample axis; `null` when there is none | +| `n_samples_used` | integer | when rows can be dropped | rows actually reduced (e.g. conditioned subsets) | +| `replicates_per_sample` | integer | yes | derived from `Seeds` (§7.1) | +| `impositions` | array of object | yes | `{param, by, action}` on varying columns (§8); `[]` if none | +| `design_shadows` | array of string | yes | design names that are varying sample columns; `[]` if none | + +`measure_source` records a fit's handle, content hash (`run_id`) and method, +never a filesystem path, so a manifest stays valid when results move. The field +is named `measure_source` because `source` is already an entry key (the +quantity's source, `quantity_output.rs:466-474`). + +## 10. Banding keys on the coordinate kind, per quantity `SimQuantities.by_scenario` becomes `by_partition: IndexMap`, keyed inside `push_cell` from the cell. At render each group is one `StackedQuantities::push_group` whose `DesignCoords` come from one function, -`DesignCoords::of(&PartitionKey, &ParamPlan, scenario_axis)`. -`DesignCoords. -sweep` already renders `sweep:` columns -(`quantity_output.rs:227-243`) and a manifest `sweep` object (`:480-491`); -`simulate` starts populating it. +`DesignCoords::of(&PartitionKey, &ParamPlan, scenario_axis)`. `DesignCoords` +already renders `sweep:` columns (`quantity_output.rs:227-243`) and a +manifest `sweep` object (`:480-491`); it gains `point_id`. -Each banded manifest entry gains one object, additive to `camdl.quantities/v1`: +Mode is decided **per quantity**, from what its band would actually reduce: -```json -"summary": { - "over": ["sample", "replicate"], - "measure": "posterior", - "source": "results/fits/sir-8a3f12b4", - "n_samples": 200, - "replicates_per_sample": 1, - "scenario_touches": { "beta": "scale" } +```rust +fn quantity_mode(plan: &ParamPlan, reps: usize, backend: ForwardBackend, q: &Quantity) -> Mode { + let sample_reduced = matches!(plan.sample(), SampleAxis::Draws { rows, .. } if rows.len() > 1); + let replicate_reduced = reps > 1 + && (backend != ForwardBackend::Ode || q.references_observations()); + if sample_reduced || replicate_reduced { Mode::Banded } else { Mode::Point } } ``` -`over` lists the band axes actually reduced in that group: `sample` unless the -axis is `Unit` or the group's scenario `set`s every sampled column; `replicate` -unless `replicates_per_sample` is 1 or the backend is ODE. `measure` is -`posterior | prior | uniform_bounds | asserted`, or `process` when only -replicates are reduced, and is absent when `over` is empty. `source` names the -fit or file where one exists. This is the label gh#575 asked for; the `n_draws` -column is not renamed here. +`StackedQuantities` takes a mode per quantity rather than one for the render +(each quantity is its own file with its own header). Two consequences, both +intended: `--replicates N` under ODE renders pure-state quantities as points +(W8), and a one-row sample (`--draws uniform -n 1`, a one-row `--draws-file`) +renders as a point rather than a zero-width band. The second reverses a pin from +`2026-08-11` §7.5 ("`--draws ` with one row stays banded"); that pin +guarded against a _cell-count_ predicate that counted scenarios, and the rule +above counts only what one group reduces, so the reason for the pin is kept +while its output changes. The test is rewritten accordingly. -## 10. Why this does not reintroduce the caller-supplied key +## 11. Why this does not reintroduce the caller-supplied key Rejection reason 1 was that `BandSet::push(key, …)` let a caller choose the key. Nothing here takes a key from a caller: @@ -956,190 +1258,343 @@ Nothing here takes a key from a caller: `engine`. `PartitionKey` has no public constructor. - `StackedQuantities::push_group` still takes `DesignCoords` from its caller. That is safe for the reason `2026-08-11` §3.5 gives — it renders groups that - are already separated and cannot re-merge them — and the coordinates it - receives are computed by `DesignCoords::of` from a key the accumulator - derived. - -Reason 2 is removed by Stage 4 (predict's sweep is in the cell). Reason 3 is the -subject of §6. Reason 4 no longer holds: `--design` and `simulate --sweep` reach -the design path in Stage 3. - -## 11. What changes for users - -| # | Command | Today | After | -| -- | ------------------------------------------------------------------ | --------------------------------------------------------- | -------------------------------------------------------------------------------- | -| 1 | `simulate --draws FILE …` | runs; pools a grid into one band under `--quantities-out` | refused, naming `--design FILE` and `--draws-file FILE` (W9) | -| 2 | `simulate --draws-file FILE` | (does not exist) | today's `--draws FILE` behaviour as a sample; allowed under a scenario (W5) | -| 3 | `simulate --design FILE`, `simulate --sweep P=GRID` | (do not exist) | one group per point, `sweep:` columns; scenarios on those params refused | -| 4 | `simulate --draws-out PATH` | writes the file | also prints `reuse as a sample with --draws-file PATH` | -| 5 | `batch run [design.NAME]` × colliding scenario | refused as "the sweep" | refused as "the design `NAME`" | -| 6 | Scenario setting a column of generated draws | runs silently | runs; one stderr line; `summary.scenario_touches` | -| 7 | Any banded quantities output | no statement of what the band is over | manifest `summary` object (additive) | -| 8 | `fit predict --sweep …` | — | byte-identical outputs | -| 9 | `--design-from` with a file | `--draws FILE` | `--draws-file` or `--design`; writes `design_from.json` recording the kind (W11) | -| 10 | `run.json` provenance tag for a draw or sweep value, where written | `sweep_point` | `sample` or `design_point` | - -**Run identity does not re-key.** Identity hashes resolved parameter values and -the process seed (`resolve.rs:202-233`, verified); §7.3 keeps every seed and -§7.4 every resolved value. The provenance tag in row 10 is recorded, not hashed, -for `sim` leaves (`run.json.inputs` is display-only, `resolve.rs:238-245`); for -fit and profile leaves, whether `parameters_provenance` (`run_meta.rs:644`) -enters any hash is _not verified_ — Stage 6 carries the test that settles it. + are already separated — and the coordinates it receives are computed by + `DesignCoords::of` from a key the accumulator derived. + +This guarantees that no caller supplies the key; it does not by itself guarantee +the _correct_ key — an accumulator could still key on `coords().scenario` alone. +That is what the mutation tests in Stage 5 (for `simulate`) and Stage 7 (for +`fit predict`) pin: key each accumulator on the scenario alone and confirm the +grouping assertions fail on row count. + +Reason 2 is removed by Stage 7 (predict's sweep is in the cell). Reason 3 is the +subject of §6. Reason 4 no longer holds: `--sweep-file` and `simulate --sweep` +reach the design path in Stage 5. + +## 12. Front-end validation + +### 12.1 The `--sweep-file` reader + +`--sweep-file` gets its own reader rather than `load_draws_tsv_keyed`, which +requires at least two columns (`main.rs:4402-4404`) and silently strips +`chain`/`draw` (`main.rs:4432`). The reader: + +- accepts one or more parameter columns; tab-separated, with a message that + detects a comma-separated file; +- takes an optional integer `point_id` column (unique, carried to output) and an + optional string `label` column (carried to the manifest); +- refuses `chain` or `draw` columns, with a hint to use `--draws-file`; +- refuses a non-finite value, naming the line and column; +- refuses duplicate rows, naming both line numbers (D-E); +- refuses a constant column, with a hint: "column `gamma` is the same on every + row, so it is not a coordinate; set it with --param or --params". + +### 12.2 Parameters from outside the file + +`--fit` backfills a draws file's missing parameters from the fit's `[fixed]` +block (#273, `main.rs:1650-1669`). For `--draws-file` the backfill stays; +backfilled columns are not file columns and are never varying. For +`--sweep-file` backfilled constants apply to every point and never become design +names. `--fit requires --draws` (`main.rs:1168`) accepts `--draws-file`. + +### 12.3 Flags, conflicts, and what the dry run says + +- `--draws` and `--draws-file` are mutually exclusive; `--sweep` and + `--sweep-file` are mutually exclusive; a sample flag and a design flag + compose. Several `--sweep` flags form a Cartesian product sorted by name; a + repeated name is refused. +- Conflict lists that name only `draws` gain `draws_file`, `sweep` and + `sweep_file`: `--stdout` (`args/mod.rs:431-434`), `--event-log` (`:563`), + `--reactive-log` (`:582`). `--reactive-log` mirrors only the last cell + (`main.rs:2116`), so a multi-cell design is refused rather than mirrored + partially. `--draws-out` (`requires = "draws"`, `:416`) stays tied to + generated draws. +- `--init-state fit` needs the posterior it pairs against (`main.rs:1149-1163`); + with a design axis it is refused for the reason `conditioned_here` refuses a + swept conditioned read. `--init-state FILE` refuses `--draws` today + (`main.rs:1314-1322`); it also refuses `--draws-file`, `--sweep-file` and + `--sweep`. +- `print_dry_run` (`main.rs:4462`) and the run-count banner + (`main.rs:1693-1701`) describe design points, samples, replicates, scenarios + and total cells; a grid above 10 000 cells prints a warning with the product + spelled out. + +### 12.4 The provenance sidecar + +`--draws-out FILE` writes `FILE.json` beside the TSV: + +| Field | Meaning | +| ---------------- | ----------------------------------------------------------------- | +| `schema` | `"camdl.draws/v1"` | +| `measure` | `posterior \| prior \| uniform_bounds` | +| `measure_source` | as in §9.3 (fit handle + run id + method, or prior source + hash) | +| `ir_hash` | structural hash of the model that generated the draws | +| `camdl_version` | `VERSION_SHORT` | +| `varying` | the columns written (§7.2) | +| `subsample` | `{"from": N, "stride": k}` when the source was strided | + +`--draws-file FILE` reads `FILE.json` when present: it carries the measure +through (a posterior stays `posterior`), warns when `ir_hash` differs from the +current model, and records in `summary` which parameters came from outside the +file and from where. `-n` subsamples a `--draws-file`, strided across all rows +with the same default cap as `--draws posterior` (gh#630). + +## 13. What changes for users, and what does not re-key + +| # | Command | Today | After | +| -- | -------------------------------------------------------------------- | ------------------------------------------- | ----------------------------------------------------------------------------------------------- | +| 1 | `simulate --draws FILE …` | runs; pools a grid under `--quantities-out` | refused, naming `--sweep-file` and `--draws-file` (W9) | +| 2 | `simulate --draws-file FILE` | (does not exist) | today's `--draws FILE` seeds and trajectories; allowed under a scenario (W5) | +| 3 | `simulate --sweep`, `--sweep-file` | (do not exist) | one group per point; CRN across points; refused against a scenario or `--param` on those params | +| 4 | `simulate --draws-out PATH` | writes the file (all columns) | writes varying columns + `PATH.json`; prints the `--draws-file` hint | +| 5 | `batch run [design.NAME]` × colliding scenario | refused as "the sweep" | refused as "the design `NAME`" | +| 6 | Scenario, `--param` or design value setting a varying sampled column | runs silently | runs; note on stderr; recorded in `summary` | +| 7 | Banded quantity files (`simulate` and `fit predict`) | no statement of what the band is over | `band` column; `point_id` on design rows; exact `sweep:` values; manifest `mode`/`summary` | +| 8 | `fit predict --sweep` after the grid move | — | byte-identical to the output after the schema commit | +| 9 | `--replicates N` under ODE; one-row samples | zero-width band | point mode for pure-state quantities | +| 10 | `--design-from` with a file | `--draws FILE` | `--draws-file` or `--sweep-file`; writes `truths.tsv`, `design_from.json` | +| 11 | `--draws-file` with `-n` | (`-n` ignored for `--draws FILE`) | strided subsample | + +**Run identity.** Every `sim` `run_id` folds the engine version, including the +git hash (`version.rs:12-15`; `ModelDigest::from_model`, +`runid/src/inputs.rs:304`; `resolve.rs:202`), so no `run_id` survives a commit +and "`run_id` unchanged" cannot be a test. The claim this proposal makes is **no +re-key beyond the existing per-build engine-version fold**: for every job that +exists today, the `params`, `scenario`, `seed` and `config` level hashes, the +store path segments below the model level, and the trajectory bytes are +unchanged. The new flags are new keys: `--sweep` and `--sweep-file` runs have no +predecessors, and a `--sweep-file` ensemble folds its axis kind (§7.6). **IR and goldens are unaffected.** All changes are in `rust/crates/cli` and docs. No `ir/VERSION` bump, no `ocaml/` change, no golden regeneration. -## 12. Staged implementation +## 14. Staged implementation -Each stage lands alone with a green `make test`. Stages 0, 1 and 5 are -byte-neutral; 2, 3 and 4 carry the user-visible changes in §11. +Each stage lands alone with a green `make test`, ships no pooling regression and +no lost refusal, and each named test fails on the defect it guards (a mutation +check is named where the test could otherwise pass vacuously). Byte-neutral +stages are checked by comparing level hashes, path segments below the model +level, and output bytes — never whole `run_id`s (§13). **Stage 0 — one constructor for `CellSpec`.** `CasSink::predict_cells` calls -`plan_grid`; the test fixtures go through a `#[cfg(test)]` constructor. _Test:_ -for a sweep × two scenarios × three seeds manifest, `--dry-run`, run, -`--dry-run` again: all-miss, then all-hit with identical leaf paths. - -**Stage 1 — `ParamPlan` and `CellCoords`.** Replace `ParamSource` (front ends -build the plan the flags they have today imply; `--draws FILE` still accepted as -an `Asserted` sample so behaviour is unchanged); split `point_overrides`; add -`seed_index`; migrate the §7.6 consumers mechanically. _Tests:_ -`tests/determinism_pin.rs` green and unchanged; a unit test planning point, +`plan_grid`. Its iteration order today is point → scenario → seed +(`batch.rs:1282-1294`), `plan_grid`'s is scenario → point → rep, and the dry-run +and status displays print in prediction order; the predictions are re-sorted to +point → scenario → seed before display, so the terminal output is unchanged. +Test fixtures go through a `#[cfg(test)]` constructor. _Test:_ for a sweep × two +scenarios × three seeds manifest, `--dry-run`'s stdout is byte-identical before +and after, then run, then `--dry-run` again reports all-hit with the same leaf +paths. + +**Stage 1 — `ParamPlan` and `CellCoords`, behaviour unchanged.** Replace +`ParamSource`; move replicate count into `Seeds`; split `point_overrides`; +migrate the §7.6 consumers mechanically, keeping today's seeds for every +existing flag (`--draws FILE` builds an `Asserted` sample with the row index as +seed index). Interim rule until Stage 5: a file-sourced sample (`Asserted`, or +from Stage 3 a sidecar-read measure) is refused against a scenario footprint +exactly as today's draws file is, with today's message, so +`tests/scenario_coordinate_collision_gh572.rs:282-334` stays green. _Tests:_ +`tests/determinism_pin.rs` unchanged and green; a unit test planning point, generated-draws, draws-file and explicit-seed sweep jobs and asserting each cell's `process_seed` equals `process_seed_for` with the old `point_idx` (the old formula kept in the test as the oracle); an A/B of -`simulate --draws uniform -n 5 --scenario a,b` and a `batch run` sweep, diffing -every artifact including `run.json`. - -**Stage 2 — the flag split and the guard on the plan.** Add `--draws-file` and -`--design`; refuse a bare `--draws FILE` (W9); `--draws-out` prints the -round-trip hint; `DesignOrigin` naming in the formatter; a sample never collides -and scenario touches are returned. Replaces the landed guard's `ParamSource` -keying (`engine.rs:367-375`) — the policy is unchanged. Doc change in the same -stage: the run-spec recipe "Posterior predictive from a draws file" -(`docs/camdl-run-spec.md:1976-1978`) becomes `--draws-file draws.tsv`, the -`--draws` help (`args/mod.rs:386-390`) and `--draws-out` help -(`args/mod.rs:412-417`) are rewritten, and the `--design-from` help names both -flags. _Tests:_ the refusal message for `--draws grid.tsv`, asserted verbatim -including both replacement flags; a round trip — -`--draws uniform -n 5 ---draws-out u.tsv`, then `--draws-file u.tsv` with the -same seed — produces leaves with the same `run_id`s as the first run; -`--draws-file u.tsv --scenario -S` (S sets a column) exits 0 with one stderr -line; `--design u.tsv --scenario S` is refused naming "the design file `u.tsv`"; -the gh#572 tests (`tests/scenario_coordinate_collision_gh572.rs`) updated for -the new nouns, with a `[design.NAME]` case asserting "the design `NAME`". -Mutation check: make the guard treat the design axis as a sample and confirm the -batch test goes red. - -**Stage 3 — design groups, `simulate --sweep`, and the `summary` block.** -`simulate --sweep` through `DesignAxis::sweep` (the lifted predict expander); -`ParamPlan::mode`; `by_partition`; `DesignCoords::of`; the manifest `summary`; -the run-spec sentence on `--draws uniform` (`docs/camdl-run-spec.md:1145-1148`) -corrected to "an iid uniform sample over declared bounds". _Tests:_ -`--sweep beta=0.15,0.3,0.6 --backend ode --dt 0.1 --quantities-out q` → three -rows `sweep:beta value` with `peak_I` 70, 304, 536 (the measured values of §3); -`--design grid.tsv` with the same three rows → byte-identical files; the same -with `--replicates 4 --backend chain_binomial` → three band rows, `n_draws = 4`, -`measure == "process"`; `--draws uniform -n 5` → `measure == "uniform_bounds"`, -`over == ["sample"]`; `--sweep` and `--design` together refused by clap. -Mutation check: key `by_partition` on the scenario alone and confirm the -three-row assertion fails on the row count, not the header. - -**Stage 4 — `fit predict` on the grid.** One plan, one `run_job` for all -scenarios and sweep points; `merge_cell` keys on `PartitionKey`; predict's guard -call deleted; `conditioned_here` reads the key (conditioned iff the scenario is -the conditioned arm and the design is `Unit` — its three conditions today). -_Tests:_ byte-identity A/B of every predict artifact (predictive TSVs, -quantities TSVs, all manifests, contrasts) for `--sweep k=… --scenario a,b` with -and without a conditioned fit, same seed; the header pin in -`tests/fit_predict_sweep.rs`; the predict sweep-collision test, now through the -engine's guard. The manifest `summary` is added in a separate commit after the -A/B passes. - -**Stage 5 — `--design-from` on the new axes.** Accept `--draws-file`, -`--design`, `--sweep`; write `design_from.json`. Byte-neutral for the dataset -files. _Test:_ `--design-from fit.toml --design truths.tsv` writes one `ds_NN/` -per row with `design_from.json` recording `"axis": "design"`; with -`--draws prior -n 3`, `"axis": "sample", "measure": "prior"`. - -**Stage 6 — provenance.** `ValueSource::{Sample, DesignPoint}`; the per-group -manifest records `requested` alongside effective design values (§14). _Test:_ -one fit leaf and one `sim` leaf before and after — `run_id` unchanged, tag -changed — which settles the unverified question in §11. - -## 13. Decisions - -**The ambiguous `--draws FILE` splits by axis.** A file's rows are either a -sample the user stands behind or conditions to compare, and no content of the -file says which. Rather than default one way and let the other become a silent -wrong answer, the flag names the axis: `--draws-file FILE` is a sample -(`Measure::Asserted`), `--design FILE` is a design (`DesignOrigin::File`), and a -bare `--draws ` is refused with a message naming both. This follows the -alpha posture — break with the replacement spelled out, no shim — and removes -the need for any modifier flag. `--draws posterior|prior|uniform` is unchanged. -`--draws-out` prints `--draws-file ` as the round-trip hint, since a -generated sample read back is still a sample. An in-file declaration was not +`simulate --draws uniform -n 5 --scenario a,b` and a `batch run` sweep comparing +level hashes, path segments below the model, and every output file's bytes. + +**Stage 2 — the guard reads the plan.** After gh#949: `varying` on `SampleAxis`; +the guard's new signature (§8); `DesignOrigin` nouns; the `scale` +reparameterization hint; `--param`/`--param-vec` in the footprint; impositions +and design shadows returned and printed; the `--event-log` path routed through +the guard; every `SimRun` site classified (§7.4). _Tests:_ the existing +design-block test (`tests/scenario_coordinate_collision_gh572.rs:235-264`) +updated to assert "the design `NAME`"; `--draws uniform --scenario pinned` exits +0 with the W3 note naming `beta`; the same with a scenario over a non-varying +column prints nothing; `--draws uniform --param beta=0.5` exits 0 and records an +imposition `"by": "--param"` (the `--param`-on-design refusal is tested in Stage +5, when `simulate` first has a design axis). Mutation check: make the guard +treat design names as sample columns and confirm the batch refusal test goes +red. + +**Stage 3 — provenance sidecar (D-D).** `--draws-out` writes varying columns and +`FILE.json`; the sidecar reader (used by `--draws FILE` until Stage 5 renames +it); `-n` subsamples a file. _Tests:_ +`--draws posterior -n 1000 --draws-out +p.tsv`, then reading `p.tsv` back yields +`measure: posterior` with the fit's handle; a sidecar whose `ir_hash` differs +prints the warning; `-n 7` on a 100-row file picks rows strided across the whole +file through the same `subsample_draws` as `--draws posterior`, never a prefix. + +**Stage 4 — output schema (D-C), its own commit, before Stage 7.** `band` +column, `point_id` on design rows, exact `sweep:` formatting, manifest `mode`, +`summary` always present, `partition_columns`, `measure_source`, `n_samples` +null when absent; `parameter_points.tsv` at round-trip precision. This +**intentionally changes `fit predict` output**, and lands before the grid move +so Stage 7's byte-identity gate is against this schema. Contrasts note and +`report.json` record under `--sweep` (W7). _Tests:_ the predict header pin in +`tests/fit_predict_sweep.rs` updated to the new header; a sweep value +`0.1234567` renders exactly; every manifest entry validates against §9.3 (a test +walking the fields). + +**Stage 5 — the flag split, `simulate --sweep`, and design grouping, together.** +`--sweep-file` (reader §12.1), `--draws-file`, `simulate --sweep`; the bare +`--draws ` refusal and the typo message; D-B seeds for design jobs; +`by_partition` and per-quantity mode (§10) in the same stage, so no build can +pool design points (the interim refusal of Stage 1 is lifted here, for +`--draws-file` only). Combined writer columns (§9.2) and the ensemble axis fold; +flag conflicts, dry-run and banner (§12.3); `--init-state` refusals; `--fit` +backfill rules. Docs in the same stage: every `--draws FILE` in +`docs/camdl-run-spec.md` (lines 1036, 1053, 1058, 1131, 1158, 1165, 1515, 2000, +2065, 2146, 5249, 5293, 6160, 6187, and the recipe at 1976-1978), +`docs/mre.md:50`, `args/mod.rs:305` (after-help), the `--draws` and `--fit` help +(`args/mod.rs:386-396`), the `--design-from` help (`:478-480`), and the run-spec +sentence on `--draws uniform` (1145-1148). Tests that pass `--draws FILE` move +to `--draws-file`: `tests/simulate_identity_parity.rs:270`, `:317`, +`tests/gh641_init_state_forecast.rs:809`, +`tests/scenario_coordinate_collision_gh572.rs:282-334` (the last now asserts the +`--sweep-file` refusal and the `--draws-file` allowance). _Tests:_ the W9 +refusal verbatim, including both replacement flags; `--draws prio` gives the +typo message; `--sweep beta=0.15,0.3,0.6 --backend ode --dt 0.1` gives rows +`0 0.15 70`, `1 0.3 304`, `2 0.6 536`; `--sweep-file` with the same three rows +gives byte-identical files; adding a fourth point leaves the first three points' +trajectory bytes unchanged (CRN); `--draws-file f.tsv` trajectories equal Stage +4's `--draws f.tsv` trajectories byte for byte; the W12 `--param` refusal; +`--sweep-file` duplicate rows refused naming both lines; ODE `--replicates 4` +renders `peak_I` in point mode while an observation quantity is banded. Mutation +check: key `by_partition` on the scenario alone and confirm the three-row +assertion fails on the row count, not the header. + +**Stage 6 — `--design-from` on the new axes.** Accept every source; priors from +the design `fit.toml`; write `truths.tsv` and `design_from.json`. Dataset files +byte-identical. _Test:_ `--sweep-file truths.tsv` writes one `ds_NN/` per row, +`truths.tsv` with `point_id`, and `"axis": "design"`; `--draws prior -n 3` gives +`"axis": "sample", "measure": "prior"` with the design fit.toml's hash. + +**Stage 7 — `fit predict` on the grid.** One plan; `DesignAxis::sweep` carries +predict's name checks; the pre-flight guard call stays before the free-forward +closure; rows rendered design-major explicitly; to bound memory, one `run_job` +per design point (a design slice) rather than one for the whole grid, since +`run_job` holds every cell's result until the merge phase (`engine.rs:232-247`); +the per-cell conditioned decision is a function of `PartitionKey` (conditioned +iff the scenario is the conditioned arm and the design is `Unit`), replacing the +per-sink `conditioned` / `chain_of_point` fields (`predict.rs:2196-2218`); +`FreeForwardCell` and `assemble_predictive` keyed by `PartitionKey`. _Tests:_ +byte-identity A/B against Stage 4 output of every predict artifact (predictive +TSVs, quantities TSVs, manifests, contrasts, `report.json`) for +`--sweep k=… --scenario a,b` with and without a conditioned fit; +`--sweep beta=… --scenario distancing` exits non-zero with **no** artifact +written; a unit test that the conditioned decision is true only for the un-swept +`fitted` group. Mutation check: key the sink on scenario alone and confirm the +four-group assertion fails. + +**Stage 8 — error text.** `ValueSource::{Sample, DesignPoint}`; +`UnknownParameter` names the column and the file; `NonFiniteValue` names the +flag the command has. _Tests:_ `params_resolver.rs` unit tests at `:2206`, +`:2238`, `:2310` updated to the new variants; a CLI test that a misspelt +`--draws-file` column prints `column`betta`of`post.tsv`is not a parameter`. + +## 15. Follow-ups, to be filed + +- **F1 — `scale` × sweep composition.** Admit a scenario that scales a swept + parameter, using the requested/effective representation in §17. +- **F2 — paired contrasts within design points, and a per-cell table.** A + free-forward contrast that pairs arms on the sample coordinate within each + design point; and an opt-in `cells.tsv` (`run_id`, `scenario`, `point_id`, + `sweep:*`, `draw`, `rep`, `process_seed`, optionally quantity values) so + paired reductions are possible from `simulate` output. +- **F3 — `batch run --quantities-out`,** so exploring a design does not need a + `simulate --sweep-file` round trip that recomputes every point (W2). + +The earlier plan to file a separate issue for common random numbers across +design points is withdrawn: D-B makes it the default for the new flags, and no +existing flag builds a design in `simulate`. + +## 16. Decisions + +**D-A — Flags come in axis-named pairs.** `--sweep` / `--sweep-file` build the +design axis and `--draws` / `--draws-file` the sample axis, matching the +`sweep:` column prefix. A file's rows are either conditions to compare or +a sample the user stands behind, and nothing in the file says which, so the flag +names the axis and a bare `--draws ` is refused naming both replacements — +the alpha posture: break with the replacement spelled out, no shim. +`[design.NAME]` in `batch run` keeps its name, because there "design" names a +generator (`sobol | lhs | random`); `--design-from` is unchanged, because there +"design" names the fit's observation schedule. An in-file declaration was not chosen: the reader takes line 1 as the header (`main.rs:4392-4399`), so a directive line would break older camdl and every other reader of these files. -**`simulate` gains `--sweep`.** Parity with `fit predict`: the same `PARAM=GRID` -grammar (`args::types::SweepSpec`) and one expansion function shared by both -verbs, producing a `DesignAxis` with origin `Sweep`. It composes with a sample -flag (design × sample) and excludes `--design` (one design axis per job). +**D-B — Design coordinates never enter the process seed.** `seed_index` is the +sample index, or 0 without a sample axis, so design points share common random +numbers by default — as they already do in `batch run` (explicit seeds) and +`fit predict` (seed follows the draw). Paired comparisons across design points +are then low-variance, and inserting a point into a sweep leaves the others' +trajectories unchanged. `--sweep` and `--sweep-file` are new, so nothing +re-keys; `--draws-file` keeps today's `--draws FILE` seeds exactly. The cost is +stated in §7.3: one file read as a design and as a sample does not share store +leaves. + +**D-C — The output schema is fixed before predict's bytes are frozen.** A +`point_id` on every design-bearing row (the file's, else the 0-based index), so +rows join without float matching; design coordinates written round-trip exact; a +constant `band` column naming what every band is over; and a manifest that +describes itself (`mode`, `summary` always, `partition_columns`, +`measure_source` with a fit handle and hash rather than a path, `n_samples` null +when absent, `n_samples_used` where rows can drop). This changes `fit predict` +output, so it lands as its own commit (Stage 4) before the grid move, whose +byte-identity gate is then against the new schema. + +**D-D — Draws files carry their provenance.** `--draws-out` writes a sidecar +recording measure, source, IR hash, version, varying columns and subsampling; +`--draws-file` reads it, so an exported posterior stays a posterior, a stale +export against a changed model is flagged, and parameters from outside the file +are named. `-n` subsamples a `--draws-file`, closing the gh#630 path by which a +raw posterior replays every row. + +**D-E — Duplicate design rows are refused only in `--sweep-file`.** In a +hand-written file a duplicate is almost always an error, so it is refused with +both line numbers. Generated grids keep accepting duplicates as today; identical +cells resolve to one leaf under effective-value identity, so the cost is a +repeated row, not a repeated simulation. + +**`simulate` gains `--sweep`** on the grammar and expansion `fit predict` +already uses (§7.6), producing a `DesignAxis` with origin `Sweep`. **`--draws uniform` is a sample with `Measure::UniformBounds`.** The uniform law -on the declared box is a real measure (§6.1), so a band over it is a correct -Monte Carlo estimate of its push-forward; what it is not is a belief, and the -manifest's `"measure": "uniform_bounds"` keeps it from being captioned as one. -Per-point rows remain one `--draws-out` / `--design` round trip away (W3). - -**`fit predict`'s sweep moves onto the grid (Stage 4), gated on byte identity.** -This is what puts predict's partition coordinate in the cell, lets one guard and -one grouping derivation serve both verbs, and removes rejection reason 2. The -objection recorded in `2026-08-11` §4 — only the scenario half of the key was -reachable — no longer holds once `simulate --sweep` and `--design` make the -design half reachable. - -**Seeds stay as they are.** Design points share common random numbers in -`batch run` (explicit seeds) and in `fit predict` (the seed follows the draw -index), and not for a design read from a file in `simulate`, where the row index -enters the seed (§7.3). Changing that would alter trajectories and re-key those -runs, and deserves its own justification; it is out of scope here and should be -filed as a separate issue. - -**Every design origin uses the `sweep:` column prefix.** One prefix for -"design coordinate" leaves `fit predict` headers and the manifest `sweep` object -unchanged; the documentation defines the prefix as a design coordinate -regardless of origin. - -**`--design-from` accepts every axis and records which.** It writes one dataset -per parameter point and reduces nothing, so neither axis changes its datasets; -but whether downstream analysis may pool across them depends on the kind, so the -kind is written into `design_from.json` (W11). - -**Contrasts stay sweep-agnostic.** A contrast forks from a smoothed state that -exists only for the un-swept model (W7). Pairing within design points is -well-defined in the coordinate algebra and is left to a free-forward contrast -feature, to be filed separately. - -## 14. Requested and effective values for a future scale × sweep composition +on the declared bounds is a real measure (§6.1), so a band over it is a correct +Monte Carlo estimate of its push-forward; the `band` value `uniform_bounds` +keeps it from being captioned as a credible interval. Per-point rows are one +`--draws-out` / `--sweep-file` round trip away (W3). + +**`fit predict`'s sweep moves onto the grid (Stage 7), gated on byte identity +against Stage 4.** It puts predict's partition coordinate in the cell and lets +one guard and one grouping derivation serve both verbs, removing rejection +reason 2. + +**Impositions are allowed and recorded, not refused.** A scenario, `--param` or +design value that sets a varying sampled column is an intervention on that +parameter; the note says so in plain words (W3) and suggests `scale` when the +intent is to reduce transmission relative to each draw. + +**Every design origin uses the `sweep:` column prefix,** leaving +`fit predict`'s header prefix and the manifest `sweep` object unchanged. + +**`--design-from` accepts every axis and records which** (W11), because whether +downstream analysis may pool across its datasets depends on the kind. + +**Contrasts stay sweep-agnostic and say so** (W7). + +## 17. Requested and effective values for a future scale × sweep composition A scenario that scales a design parameter is refused (§8), so a design -coordinate's requested value always equals the value the cell ran. If a later -proposal admits composition — sweep `beta ∈ {0.15, 0.3, 0.6}` under `distancing` -running `{0.09, 0.18, 0.36}` — the representation is fixed here: +coordinate's requested value always equals the value the cell ran. If F1 admits +composition — sweep `beta ∈ {0.15, 0.3, 0.6}` under `distancing` running +`{0.09, 0.18, 0.36}` — the representation is fixed here: - **Identity hashes only effective values.** It does already (`batch.rs:1141-1153`, `resolve.rs:231`, verified). Cells whose effective - values coincide under different requested coordinates share a `run_id` and are - one computation in the store. -- **The grouping key is the requested coordinate** — the index of the axis the - user chose (`DesignCoord::Point { idx }`), not a value. + values coincide under different requested coordinates share their level hashes + and are one computation in the store. +- **The grouping key is the requested coordinate** — the design point's index + (`DesignCoord::Point { idx }`), reported as `point_id`. - **A column denotes one thing.** `sweep:beta` keeps meaning the requested grid value; the effective value, where it differs, is reported under a separately - named column and in the manifest's per-group `requested`/`effective` pair - (Stage 6 writes both; they are equal until composition exists). - -Admitting composition is out of scope; this section fixes only where its -metadata would go. + named column and in the manifest's per-group `requested`/`effective` pair. From c0d633f1186e21daf18f448575b45096526afd08 Mon Sep 17 00:00:00 2001 From: Vince Buffalo Date: Tue, 29 Sep 2026 11:48:12 -0700 Subject: [PATCH 4/5] docs(proposal): apply the coordinate-kind feasibility review Twelve corrections from the feasibility re-review. Point versus band is now decided by the data in each group rather than by the backend: a quantity collapses to a point only when every group's realizations are bit-identical, and the band column is set per row from what actually varied. --draws-file gets no default row cap, so it keeps today's --draws FILE seeds; with -n the seed index is the position in the subsample. Stage 7 is gated on byte identity against its parent commit, and it slices one global plan by design index so partition keys stay global. Predict's design shadows and varying list land in the commit after the A/B passes; the A/B compares report.json and manifests unmasked, since they carry no version string or timestamp. Also: the ensemble axis-kind fold is an optional field, so no existing grid hash moves; the CRN test and the Stage 2 test are made able to fail; posterior varying columns fall back to non-constant columns when estimated is empty; imposition notes print once per invocation; the no-re-key baseline is main after gh#949. Relates-to: gh#562, gh#572, gh#630, gh#949 --- ...-09-27-coordinate-kind-design-vs-sample.md | 325 ++++++++++++------ 1 file changed, 224 insertions(+), 101 deletions(-) diff --git a/docs/dev/proposals/2026-09-27-coordinate-kind-design-vs-sample.md b/docs/dev/proposals/2026-09-27-coordinate-kind-design-vs-sample.md index 5f84c221..a129af01 100644 --- a/docs/dev/proposals/2026-09-27-coordinate-kind-design-vs-sample.md +++ b/docs/dev/proposals/2026-09-27-coordinate-kind-design-vs-sample.md @@ -487,14 +487,18 @@ fitted 1 0.12 posterior 200 … … … … distancing 1 0.12 posterior 200 … … … … ``` -**After Stage 7** (the sweep moves onto the grid) the bytes are identical to the -Stage 4 output: rows stay design-major by explicit rendering order. The summary -records the design's imposition on the posterior: `"design_shadows": ["gamma"]`, -and `measure` reads `"posterior"` with +**After Stage 7** (the sweep moves onto the grid) the bytes are identical to +those of Stage 7's parent commit: rows stay design-major by explicit rendering +order. **After the commit that follows Stage 7's A/B**, predict's summary +records the design's imposition on the posterior: `"varying"` lists the fit's +estimated parameters, `"design_shadows": ["gamma"]`, and `measure` reads +`"posterior"` with `"measure_note": "posterior of the other parameters with gamma imposed by the design"`; -one stderr note says the same. `--sweep beta=… --scenario distancing` is refused -before any artifact is written (§14, Stage 7), naming "the sweep". +one stderr note says the same. Stage 4 does not emit these three fields for +predict, because predict's sweep is not yet a design axis there. +`--sweep beta=… --scenario distancing` is refused before any artifact is written +(§14, Stage 7), naming "the sweep". ### W7 — Contrasts: how many infections does an intervention avert? @@ -555,13 +559,16 @@ process 50 … … … … … `--seeds 1,2,3` is the same with explicit seeds. Under `--backend ode` the solver draws no randomness, so replicates repeat one -trajectory. A pure-state quantity (`peak_I`, `final_R`) then renders in point -mode — one `value` — with `"over": []`, rather than `n_draws = 50` and -`q05 = q95`. A quantity that reads `observations.` still varies across -replicates, because each replicate's observation draw uses its own seed -(`main.rs:2447-2460`), and stays banded with `"over": ["replicate"]`. Mode is -therefore decided per quantity (§10). Nothing collides: there are no parameter -coordinates. +trajectory. A pure-state quantity (`peak_I`, `final_R`) then has 50 +bit-identical realizations in its group and renders in point mode — one `value` +— with `"over": []`, rather than `n_draws = 50` and `q05 = q95`. A quantity that +reads `observations.` still varies across replicates, because each +replicate's observation draw uses its own seed (`main.rs:2447-2460`), so its +realizations differ and it stays banded with `"over": ["replicate"]`. The same +data rule covers `--init-state FILE` under ODE: replicate `i` restores row `i` +of the state file (`engine.rs:452-455`), so realizations differ and the quantity +bands. Mode is decided per quantity and per group from the realizations +themselves (§10). Nothing collides: there are no parameter coordinates. ### W9 — A hand-authored grid passed the old way @@ -685,9 +692,15 @@ camdl simulate sir.camdl --draws-file post.tsv -n 200 --quantities-out q The first command writes `post.tsv` (varying columns only) and `post.tsv.json`. The second reads both: `-n 200` subsamples the file strided across all rows -(today `-n` is ignored for a file, so a raw 60k-row `draws.tsv` replays every -row — the gh#630 hazard); `band` is `posterior`; the summary's `n_samples` is -200 and `measure_source` names the original fit and records +through the same `subsample_draws` as `--draws posterior`, and the seed index of +each kept row is its position in the subsample (0…199), as for +`--draws +posterior`. Without `-n` every row runs, at the row index as seed index +— there is no default cap, so `--draws-file` keeps today's `--draws FILE` seeds +exactly; a file above 1000 rows prints a warning suggesting `-n` (today `-n` is +ignored for a file, so a raw 60k-row `draws.tsv` replays every row silently — +the gh#630 hazard). `band` is `posterior`; the summary's `n_samples` is 200 and +`measure_source` names the original fit and records `"subsample": {"from": 1000, "stride": 5}`. If `sir.camdl` has changed since the export, the IR hash in the sidecar differs and a warning says so; parameters the file does not carry are listed in `"from_outside_file": [...]` with where their @@ -907,6 +920,21 @@ scenario that sets every varying column leaves nothing to pool over the sample). | `UniformBounds` | parameters with declared bounds (the ones actually drawn) | | `Asserted` | the sidecar's list if present, else the file's non-constant columns | +For a posterior, the estimated set is `fit.meta.json`'s `estimated` list. That +field is `#[serde(default)]` (`run_meta.rs:637-638`, verified), so an older or +hand-built fit can carry an empty list; `varying` then falls back to the draws' +non-constant columns, and the summary records `"varying_from": "columns"` rather +than `"estimated"`. The name mapping: the existing chain-subset diagnostics +already join `estimated` to `draws.tsv` columns by exact name +(`chain_selection.rs:295-306`), which holds for scalar parameters. Whether an +indexed family appears in `estimated` as its flattened columns (`beta_1`, +`beta_2`, the `draws.tsv` convention) or as the family name (`beta`) was not +verified here. The rule adopted covers both: an `estimated` entry matches the +column of the same name, and a family name with no column of its own matches +every flattened column `_`; an `estimated` entry matching no +column is an error naming it. Stage 2 pins the rule with a test on an indexed +model. + ### 7.3 Design coordinates never enter the process seed ```rust @@ -928,7 +956,11 @@ index). Consequences, job by job: - `--draws posterior|prior|uniform` and `--draws-file`: sample index = today's `point_idx`; every trajectory is unchanged. `--draws-file FILE` has exactly - the seeds of today's `--draws FILE`. + the seeds of today's `--draws FILE`: it has no default row cap, so without + `-n` every row runs at its row index. With `-n`, the file is subsampled and + the seed index is the row's position in the subsample, as for + `--draws + posterior` today. - `--sweep` and `--sweep-file` are new; they have no runs to preserve. Every design point uses `seed_index = 0`, so inserting or removing a point leaves the other points' trajectories unchanged. @@ -1074,7 +1106,14 @@ zipped (non-Cartesian) design is a `--sweep-file`. The `explicit_file` field The `SimEnsemble` grid level folds the axis kind for design jobs so that `--sweep-file X` and `--draws-file X` cannot produce one ensemble `run_id` over -different bytes (the two write different combined-file columns, §9.2). +different bytes (the two write different combined-file columns, §9.2). The fold +is an optional field of `EnsembleGridLevel` (`sim_ensemble_cas.rs:101`, built at +`:132`), serialized only when present +(`skip_serializing_if = "Option::is_none"`) and set only for jobs with a design +axis. Every job that exists today has none, so no existing ensemble grid hash +changes; Stage 5 tests both halves — a `--draws uniform` ensemble's grid-level +hash equals its value on the parent commit, and a `--sweep-file X` and a +`--draws-file X` ensemble over the same file have different grid-level hashes. ## 8. Collision policy keys on the coordinate kind @@ -1102,10 +1141,16 @@ The footprint of each overriding source is intersected with the plan: case names the reparameterization route (W10). - **Against varying sample columns** — allowed; each hit is returned as an `Imposition { param, by: Scenario(name) | FixedCli, action: Set | Scale }`, - which `run_job` prints (one note per `set`, W3 wording) and the quantity - accumulators copy into `summary.impositions`. + which the front end prints (W3 wording) and the quantity accumulators copy + into `summary.impositions`. - **Design names against varying sample columns** — allowed; returned as design - shadows (§6.2), printed once, and copied into `summary.design_shadows`. + shadows (§6.2), printed, and copied into `summary.design_shadows`. + +Notes are printed **once per (source, column) per invocation**, never per cell +and never per `run_job` call. The guard returns the list; the verb's front end +deduplicates it and prints before the first cell runs. This matters for +`fit predict`, which calls `run_job` once per design slice (Stage 7), and would +otherwise repeat each note per slice. Because `fit predict --sweep` builds a plan with a design axis after Stage 7, the engine's guard covers it; predict nevertheless keeps an explicit pre-flight @@ -1125,19 +1170,19 @@ This section is normative. It applies to `simulate --quantities-out`, Columns appear in this order; each group is present only when its condition holds. -| # | Column(s) | Present when | -| -- | ------------------------------------- | --------------------------------------------------------------------------------- | -| 1 | `scenario` | `simulate`: any `--scenario` given; `fit predict`: always | -| 2 | `point_id` | the job has a design axis | -| 3 | `sweep:` … | the job has a design axis; one per design name, sorted | -| 4 | `time` | the quantity is a series | -| 5 | `` … | the quantity is stratified | -| 6 | `band` | banded mode; one of `posterior \| prior \| uniform_bounds \| asserted \| process` | -| 7 | `n_draws` | banded mode | -| 8 | `rhat`, `ess` | banded mode with a chain partition (`fit predict`) | -| 9 | `n_value`, `n_censored`, `p_censored` | banded mode, censorable scalar | -| 10 | `q05 q25 q50 q75 q95` | banded mode | -| 10 | `value` | point mode | +| # | Column(s) | Present when | +| -- | ------------------------------------- | ------------------------------------------------------------------------------------------- | +| 1 | `scenario` | `simulate`: any `--scenario` given; `fit predict`: always | +| 2 | `point_id` | the job has a design axis | +| 3 | `sweep:` … | the job has a design axis; one per design name, sorted | +| 4 | `time` | the quantity is a series | +| 5 | `` … | the quantity is stratified | +| 6 | `band` | banded mode; per row: `posterior \| prior \| uniform_bounds \| asserted \| process \| none` | +| 7 | `n_draws` | banded mode | +| 8 | `rhat`, `ess` | banded mode with a chain partition (`fit predict`) | +| 9 | `n_value`, `n_censored`, `p_censored` | banded mode, censorable scalar | +| 10 | `q05 q25 q50 q75 q95` | banded mode | +| 10 | `value` | point mode | Examples: point, design, no scenario — `point_id sweep:beta value`; banded, scenario, sample — `scenario band n_draws q05 … q95`; banded, scenario, @@ -1145,13 +1190,17 @@ design, sample, chains — `scenario point_id sweep:gamma band n_draws rhat ess q05 … q95`. -`band` names the measure when the sample axis is reduced, else `process`. It is -constant within a file. `sweep:` values are written with Rust's shortest -round-trip `f64` formatting (`{}`), never through `quantile::fmt_value` (6 -decimals), so a coordinate joins exactly against the grid that produced it. An -indexed parameter flattens as it does elsewhere in camdl (`beta_1`, no -brackets); the manifest's `sweep` object additionally records -`{"family": "beta", "index": [1]}` for such entries. +`band` is set **per row**, from what varied in that row's group (§10): the +measure name when the group's parameter vectors differ, `process` when only the +stochastic realizations differ, `none` for a group whose realizations are +bit-identical in a file that bands because other groups vary. It is not constant +within a file: in a posterior run, an arm whose scenario sets every varying +column reads `process` beside arms that read `posterior`. `sweep:` values +are written with Rust's shortest round-trip `f64` formatting (`{}`), never +through `quantile::fmt_value` (6 decimals), so a coordinate joins exactly +against the grid that produced it. An indexed parameter flattens as it does +elsewhere in camdl (`beta_1`, no brackets); the manifest's `sweep` object +additionally records `{"family": "beta", "index": [1]}` for such entries. Readers: the colon in `sweep:beta` requires backticks in R's `readr::read_tsv` (`` df$`sweep:beta` ``) and becomes `sweep.beta` under `read.delim`; polars and @@ -1198,7 +1247,7 @@ Every entry of `quantities.json` carries: | Field | Type | Req. | Meaning | | ----------------------- | -------------------------------- | ------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `over` | array of `"sample"\|"replicate"` | yes | band axes reduced in this group; `[]` in point mode | +| `over` | array of `"sample"\|"replicate"` | yes | band axes that actually varied in this group (§10); `[]` in point mode | | `measure` | string | when `over` is non-empty | `posterior \| prior \| uniform_bounds \| asserted \| process` | | `measure_note` | string | no | e.g. "posterior of the other parameters with gamma imposed by the design" | | `measure_source` | object | when `measure` is not `process` | `{"kind":"fit","handle","run_id","method"}`, `{"kind":"model_ir","ir_hash"}`, `{"kind":"fit_toml","path_sha256"}`, `{"kind":"file","sha256"}`, plus `subsample` when strided | @@ -1225,26 +1274,43 @@ from the cell. At render each group is one `StackedQuantities::push_group` whose already renders `sweep:` columns (`quantity_output.rs:227-243`) and a manifest `sweep` object (`:480-491`); it gains `point_id`. -Mode is decided **per quantity**, from what its band would actually reduce: - -```rust -fn quantity_mode(plan: &ParamPlan, reps: usize, backend: ForwardBackend, q: &Quantity) -> Mode { - let sample_reduced = matches!(plan.sample(), SampleAxis::Draws { rows, .. } if rows.len() > 1); - let replicate_reduced = reps > 1 - && (backend != ForwardBackend::Ode || q.references_observations()); - if sample_reduced || replicate_reduced { Mode::Banded } else { Mode::Point } -} -``` - -`StackedQuantities` takes a mode per quantity rather than one for the render -(each quantity is its own file with its own header). Two consequences, both -intended: `--replicates N` under ODE renders pure-state quantities as points -(W8), and a one-row sample (`--draws uniform -n 1`, a one-row `--draws-file`) -renders as a point rather than a zero-width band. The second reverses a pin from -`2026-08-11` §7.5 ("`--draws ` with one row stays banded"); that pin -guarded against a _cell-count_ predicate that counted scenarios, and the rule -above counts only what one group reduces, so the reason for the pin is kept -while its output changes. The test is rewritten accordingly. +Point versus band is decided **by the data, per group**, not inferred from the +backend or the quantity's source. A static rule cannot be right: whether +replicates differ depends on the backend, on whether the quantity reads the +observation draw (`references_observations`, `sim/src/quantity.rs:252`, is a +property of the whole evaluator, not of one quantity), on `--init-state FILE` +(which restores a different state row per replicate under any backend, +`engine.rs:452-455`), and on whether a scenario sets every varying column. The +realizations themselves settle all four. + +For each quantity and each group, after all cells are merged: + +- the group is **constant** when every realization is bit-identical (compared on + the `f64` bit patterns, a censored value equal only to a censored value); +- the group's **attribution** is `sample` when the effective parameter vectors + of its cells differ (after impositions), else `process` when the realizations + differ, else none. + +A quantity renders in **point mode** when every one of its groups is constant; +its constant groups are collapsed to one realization _before_ +`render_quantities` is called, whose point mode refuses more than one +(`quantity_output.rs:351`, verified). Otherwise the quantity renders in **banded +mode** for all its groups, because one file has one header; each row's `band` is +the group's attribution (the measure name for `sample`, `process`, or `none` for +a constant group in a banded file), and `summary.over` lists what actually +varied in that group. `StackedQuantities` therefore takes a mode per quantity +rather than one for the render. + +Consequences, all intended: `--replicates N` under ODE renders pure-state +quantities as points and observation quantities as bands (W8); a one-row sample +(`--draws uniform -n 1`, a one-row `--draws-file`) renders as a point rather +than a zero-width band; and in a posterior run where one arm's scenario sets +every varying column, that arm's rows read `band = process` while the other arms +read `posterior`. The second reverses a pin from `2026-08-11` §7.5 +("`--draws ` with one row stays banded"); that pin guarded against a +_cell-count_ predicate that counted scenarios, and the rule above counts only +what one group reduces, so the reason for the pin is kept while its output +changes. The test is rewritten accordingly. ## 11. Why this does not reintroduce the caller-supplied key @@ -1336,8 +1402,11 @@ names. `--fit requires --draws` (`main.rs:1168`) accepts `--draws-file`. `--draws-file FILE` reads `FILE.json` when present: it carries the measure through (a posterior stays `posterior`), warns when `ir_hash` differs from the current model, and records in `summary` which parameters came from outside the -file and from where. `-n` subsamples a `--draws-file`, strided across all rows -with the same default cap as `--draws posterior` (gh#630). +file and from where. `-n` subsamples a `--draws-file` only when given, strided +across all rows through `subsample_draws`; the seed index is then the position +in the subsample. There is no default cap — a cap would change which rows and +seeds today's `--draws FILE` runs — so a file above 1000 rows prints a warning +suggesting `-n` instead (gh#630). ## 13. What changes for users, and what does not re-key @@ -1350,10 +1419,10 @@ with the same default cap as `--draws posterior` (gh#630). | 5 | `batch run [design.NAME]` × colliding scenario | refused as "the sweep" | refused as "the design `NAME`" | | 6 | Scenario, `--param` or design value setting a varying sampled column | runs silently | runs; note on stderr; recorded in `summary` | | 7 | Banded quantity files (`simulate` and `fit predict`) | no statement of what the band is over | `band` column; `point_id` on design rows; exact `sweep:` values; manifest `mode`/`summary` | -| 8 | `fit predict --sweep` after the grid move | — | byte-identical to the output after the schema commit | -| 9 | `--replicates N` under ODE; one-row samples | zero-width band | point mode for pure-state quantities | +| 8 | `fit predict --sweep` after the grid move | — | byte-identical to Stage 7's parent commit | +| 9 | Groups whose realizations are all bit-identical | zero-width band | point mode when every group of the quantity is constant (§10) | | 10 | `--design-from` with a file | `--draws FILE` | `--draws-file` or `--sweep-file`; writes `truths.tsv`, `design_from.json` | -| 11 | `--draws-file` with `-n` | (`-n` ignored for `--draws FILE`) | strided subsample | +| 11 | `--draws-file` with `-n`; large files without `-n` | (`-n` ignored for `--draws FILE`) | strided subsample with `-n`; every row without it, plus a warning above 1000 rows | **Run identity.** Every `sim` `run_id` folds the engine version, including the git hash (`version.rs:12-15`; `ModelDigest::from_model`, @@ -1362,8 +1431,12 @@ and "`run_id` unchanged" cannot be a test. The claim this proposal makes is **no re-key beyond the existing per-build engine-version fold**: for every job that exists today, the `params`, `scenario`, `seed` and `config` level hashes, the store path segments below the model level, and the trajectory bytes are -unchanged. The new flags are new keys: `--sweep` and `--sweep-file` runs have no -predecessors, and a `--sweep-file` ensemble folds its axis kind (§7.6). +unchanged. The baseline is `main` **after gh#949**, not `ebe845a0`: gh#949 stops +generated rows from carrying parameters the measure does not sample, which by +itself changes resolved values (and so `params` level hashes) for +`--params X --draws uniform|prior`, and that change is gh#949's, not this +proposal's. The new flags are new keys: `--sweep` and `--sweep-file` runs have +no predecessors, and a `--sweep-file` ensemble folds its axis kind (§7.6). **IR and goldens are unaffected.** All changes are in `rust/crates/cli` and docs. No `ir/VERSION` bump, no `ocaml/` change, no golden regeneration. @@ -1408,12 +1481,18 @@ and design shadows returned and printed; the `--event-log` path routed through the guard; every `SimRun` site classified (§7.4). _Tests:_ the existing design-block test (`tests/scenario_coordinate_collision_gh572.rs:235-264`) updated to assert "the design `NAME`"; `--draws uniform --scenario pinned` exits -0 with the W3 note naming `beta`; the same with a scenario over a non-varying -column prints nothing; `--draws uniform --param beta=0.5` exits 0 and records an -imposition `"by": "--param"` (the `--param`-on-design refusal is tested in Stage -5, when `simulate` first has a design axis). Mutation check: make the guard -treat design names as sample columns and confirm the batch refusal test goes -red. +0 with the W3 note naming `beta`; a scenario over a column that rows carry but +the measure does not vary prints nothing and records nothing — tested with +posterior draws from a fit whose `[fixed]` block pins `N0` (the posterior +`draws.tsv` carries the `N0` column, constant) under a scenario setting `N0`, +and with a `--draws FILE` whose `N0` column is constant (the Stage 1 interim +refusal applies to scenario footprints over file columns, so this case uses +`--param N0=…` instead); a posterior over an indexed family pins the §7.2 +`estimated`-to-column mapping; `--draws uniform --param beta=0.5` exits 0 and +records an imposition `"by": "--param"` (the `--param`-on-design refusal is +tested in Stage 5, when `simulate` first has a design axis). Mutation check: +make the guard treat design names as sample columns and confirm the batch +refusal test goes red. **Stage 3 — provenance sidecar (D-D).** `--draws-out` writes varying columns and `FILE.json`; the sidecar reader (used by `--draws FILE` until Stage 5 renames @@ -1422,12 +1501,18 @@ it); `-n` subsamples a file. _Tests:_ p.tsv`, then reading `p.tsv` back yields `measure: posterior` with the fit's handle; a sidecar whose `ir_hash` differs prints the warning; `-n 7` on a 100-row file picks rows strided across the whole -file through the same `subsample_draws` as `--draws posterior`, never a prefix. +file through the same `subsample_draws` as `--draws posterior`, never a prefix, +with seed index = position in the subsample; and a 250-row file read without +`-n` runs all 250 rows with trajectory bytes identical to the parent commit (no +default cap), printing no warning, while a 1500-row file prints the `-n` +warning. **Stage 4 — output schema (D-C), its own commit, before Stage 7.** `band` column, `point_id` on design rows, exact `sweep:` formatting, manifest `mode`, `summary` always present, `partition_columns`, `measure_source`, `n_samples` -null when absent; `parameter_points.tsv` at round-trip precision. This +null when absent; `parameter_points.tsv` at round-trip precision. For +`fit predict`, Stage 4 does not emit `varying`, `design_shadows` or +`measure_note`: predict's sweep is not a design axis until Stage 7. This **intentionally changes `fit predict` output**, and lands before the grid move so Stage 7's byte-identity gate is against this schema. Contrasts note and `report.json` record under `--sweep` (W7). _Tests:_ the predict header pin in @@ -1455,13 +1540,25 @@ to `--draws-file`: `tests/simulate_identity_parity.rs:270`, `:317`, refusal verbatim, including both replacement flags; `--draws prio` gives the typo message; `--sweep beta=0.15,0.3,0.6 --backend ode --dt 0.1` gives rows `0 0.15 70`, `1 0.3 304`, `2 0.6 536`; `--sweep-file` with the same three rows -gives byte-identical files; adding a fourth point leaves the first three points' -trajectory bytes unchanged (CRN); `--draws-file f.tsv` trajectories equal Stage -4's `--draws f.tsv` trajectories byte for byte; the W12 `--param` refusal; -`--sweep-file` duplicate rows refused naming both lines; ODE `--replicates 4` -renders `peak_I` in point mode while an observation quantity is banded. Mutation -check: key `by_partition` on the scenario alone and confirm the three-row -assertion fails on the row count, not the header. +gives byte-identical files; common random numbers, with +`--backend +chain_binomial --replicates 3`: every design point's cells carry the +same three `process_seed`s, and inserting a new point at the front and, +separately, in the middle of the sweep leaves every other point's trajectory +bytes identical; `--draws-file f.tsv` trajectories equal the parent commit's +`--draws f.tsv` trajectories byte for byte, for a 5-row file and for a 250-row +file (above `--draws posterior`'s default cap of 200, so a cap would show); the +ensemble axis-kind fold (§7.6) leaves a `--draws uniform` ensemble's grid-level +hash unchanged and separates `--sweep-file X` from `--draws-file X`; imposition +notes print once per (scenario, column) for a two-scenario, three-point run; the +W12 `--param` refusal; `--sweep-file` duplicate rows refused naming both lines; +ODE `--replicates 4` renders `peak_I` in point mode while an observation +quantity is banded; an ODE `--init-state FILE --replicates 4` bands `peak_I` +(rows differ); in a `--draws uniform` run with scenarios `baseline,pinned` where +`beta` is the only varying column, the `pinned` rows read `band = process` and +the `baseline` rows `uniform_bounds`. Mutation check: key `by_partition` on the +scenario alone and confirm the three-row assertion fails on the row count, not +the header. **Stage 6 — `--design-from` on the new axes.** Accept every source; priors from the design `fit.toml`; write `truths.tsv` and `design_from.json`. Dataset files @@ -1473,18 +1570,36 @@ byte-identical. _Test:_ `--sweep-file truths.tsv` writes one `ds_NN/` per row, predict's name checks; the pre-flight guard call stays before the free-forward closure; rows rendered design-major explicitly; to bound memory, one `run_job` per design point (a design slice) rather than one for the whole grid, since -`run_job` holds every cell's result until the merge phase (`engine.rs:232-247`); -the per-cell conditioned decision is a function of `PartitionKey` (conditioned -iff the scenario is the conditioned arm and the design is `Unit`), replacing the -per-sink `conditioned` / `chain_of_point` fields (`predict.rs:2196-2218`); -`FreeForwardCell` and `assemble_predictive` keyed by `PartitionKey`. _Tests:_ -byte-identity A/B against Stage 4 output of every predict artifact (predictive -TSVs, quantities TSVs, manifests, contrasts, `report.json`) for -`--sweep k=… --scenario a,b` with and without a conditioned fit; +`run_job` holds every cell's result until the merge phase (`engine.rs:232-247`). +A slice is **one global plan filtered by design index** — `plan_grid` over the +whole plan, then the cells whose `DesignCoord::Point { idx }` equals the slice — +never a per-point sub-plan, so every cell keeps its global design index and +partition keys from different slices cannot collide; the per-cell conditioned +decision is a function of `PartitionKey` (conditioned iff the scenario is the +conditioned arm and the design is `Unit`), replacing the per-sink `conditioned` +/ `chain_of_point` fields (`predict.rs:2196-2218`); `FreeForwardCell` and +`assemble_predictive` keyed by `PartitionKey`. _Tests:_ byte-identity A/B +against **Stage 7's parent commit** (post-Stage 6) of every predict artifact +(predictive TSVs, quantities TSVs, manifests, contrasts, `report.json`) for +`--sweep k=… --scenario a,b` with and without a conditioned fit. These files +carry no version string or timestamp, so they are compared unmasked: +`report.json` holds only `schema` and `failures` (`fit/failures.rs:97-100`, +verified), the quantities manifest holds `schema`, `calendar` and `quantities` +plus, under `--quantities`, a `vocabulary` object of file path and sha256 +(`quantity_output.rs:511-515`, `quantities_file.rs:66-71`, verified), and +neither `predict.rs`, `quantity_output.rs` nor `contrasts.rs` references +`version::` or a clock (verified by search); the A/B runs both builds from the +same working directory so the vocabulary path is equal. A unit test runs two +design points of one global plan as two slices through one sink and one +`FreeForwardCell` map and asserts two groups with distinct partition keys (a +per-slice plan would give both `design: Some(0)` and merge them); `--sweep beta=… --scenario distancing` exits non-zero with **no** artifact written; a unit test that the conditioned decision is true only for the un-swept `fitted` group. Mutation check: key the sink on scenario alone and confirm the -four-group assertion fails. +four-group assertion fails. **After the A/B passes**, a separate commit adds +predict's `varying`, `design_shadows` and `measure_note` to the manifest and the +design-shadow stderr note (W6); it is an intended output change, tested by +asserting `"design_shadows": ["gamma"]` for `--sweep gamma=…`. **Stage 8 — error text.** `ValueSource::{Sample, DesignPoint}`; `UnknownParameter` names the column and the file; `NonFiniteValue` names the @@ -1535,19 +1650,25 @@ leaves. **D-C — The output schema is fixed before predict's bytes are frozen.** A `point_id` on every design-bearing row (the file's, else the 0-based index), so rows join without float matching; design coordinates written round-trip exact; a -constant `band` column naming what every band is over; and a manifest that -describes itself (`mode`, `summary` always, `partition_columns`, +`band` column naming, per row, what that group's band is over; and a manifest +that describes itself (`mode`, `summary` always, `partition_columns`, `measure_source` with a fit handle and hash rather than a path, `n_samples` null when absent, `n_samples_used` where rows can drop). This changes `fit predict` output, so it lands as its own commit (Stage 4) before the grid move, whose -byte-identity gate is then against the new schema. +byte-identity gate is then against its parent commit, already on the new schema. +Point versus band is decided from the realizations of each group (§10), not from +the backend or the quantity's source, so `summary.over` reports what actually +varied. **D-D — Draws files carry their provenance.** `--draws-out` writes a sidecar recording measure, source, IR hash, version, varying columns and subsampling; `--draws-file` reads it, so an exported posterior stays a posterior, a stale export against a changed model is flagged, and parameters from outside the file -are named. `-n` subsamples a `--draws-file`, closing the gh#630 path by which a -raw posterior replays every row. +are named. `-n` subsamples a `--draws-file` when given (seed index = position in +the subsample), closing the gh#630 path by which a raw posterior replays every +row unasked. There is no default cap: a cap would change the rows and seeds of +today's `--draws FILE` runs and break D-B's promise that `--draws-file` keeps +them exactly; a file above 1000 rows warns instead. **D-E — Duplicate design rows are refused only in `--sweep-file`.** In a hand-written file a duplicate is almost always an error, so it is refused with @@ -1565,9 +1686,11 @@ keeps it from being captioned as a credible interval. Per-point rows are one `--draws-out` / `--sweep-file` round trip away (W3). **`fit predict`'s sweep moves onto the grid (Stage 7), gated on byte identity -against Stage 4.** It puts predict's partition coordinate in the cell and lets -one guard and one grouping derivation serve both verbs, removing rejection -reason 2. +against its parent commit.** Slices are one global plan filtered by design +index, so partition keys stay global; predict's design shadows and `varying` are +added in the commit after the A/B passes. It puts predict's partition coordinate +in the cell and lets one guard and one grouping derivation serve both verbs, +removing rejection reason 2. **Impositions are allowed and recorded, not refused.** A scenario, `--param` or design value that sets a varying sampled column is an intervention on that From a68e7457e50c09434eaa46ec0877ee3f3f975fe0 Mon Sep 17 00:00:00 2001 From: Vince Buffalo Date: Sun, 4 Oct 2026 17:52:33 -0700 Subject: [PATCH 5/5] docs(proposal): apply the coordinate-kind consistency review Rebased onto main with gh#949 landed; every citation is re-pinned to 15cf347c. A posterior --draws-out keeps today's columns, the fit's [fixed] columns included, so a reused posterior needs no --fit; only the sidecar's varying list narrows to the estimated set. --design-from keeps dataset-index seeds as a stated exception to the design-seed rule, since its datasets are independent replicates. Stage 4 adds per-group attribution for both verbs under today's mode predicate, Stage 5 moves both to the bit-identical rule, and Stage 7's byte gate sits on a parent already on the final rule. The guard returns impositions and design shadows as separate lists. The types gain SampleProvenance, DesignRow, FitRef, PriorSource and a Seeds that holds the replicate count. The manifest becomes camdl.quantities/v2 with its remaining fields defined. simulate sorts quantity groups design-major explicitly. The --fit source check accepts the new flags, the draws reader loses its two-column minimum, and the constant-column refusal needs two or more rows. Smaller wording and interface fixes from the same review are folded in. Relates-to: gh#562, gh#572, gh#949 --- ...-09-27-coordinate-kind-design-vs-sample.md | 748 +++++++++++------- 1 file changed, 456 insertions(+), 292 deletions(-) diff --git a/docs/dev/proposals/2026-09-27-coordinate-kind-design-vs-sample.md b/docs/dev/proposals/2026-09-27-coordinate-kind-design-vs-sample.md index a129af01..f088e8b9 100644 --- a/docs/dev/proposals/2026-09-27-coordinate-kind-design-vs-sample.md +++ b/docs/dev/proposals/2026-09-27-coordinate-kind-design-vs-sample.md @@ -8,12 +8,12 @@ - Builds on: an untracked architecture-review note on gh#562 (2026-08-11). Every claim this proposal uses from it is restated in §4; the note is not needed to read this document. -- Prerequisite: gh#949 (generated prior/uniform rows carry parameters the - measure does not sample, so a scenario's value leaks into other arms and - `--draws-out` exports it) is being fixed separately. §7.2's "varying columns" - builds on that fix. +- Builds on gh#949 (landed, `198dc096`): generated `prior` and `uniform` rows + now carry only the parameters the measure varies, the rest resolve per cell + through the ordinary tiers, and a per-arm pre-flight refuses a parameter no + tier sets. §7.2's "varying columns" rests on it. -All code references are against `origin/main` at `ebe845a0`. A reference marked +All code references are against `origin/main` at `15cf347c`. A reference marked _verified_ was read at that line; _inferred_ means reasoned from the code but not executed. Output shown under **after** is the format this proposal specifies; it does not exist yet. @@ -108,13 +108,14 @@ combines cells from _different_ values of a partition axis that share a band coordinate — draw _i_ of the baseline arm with draw _i_ of the intervention arm — to construct a new random variable before any marginal summary. -An **imposition** is a value set on a varying sampled column by something other -than the measure — a scenario `set`, a `--param`, or a design coordinate. Every -sample then runs at that value while its other parameters keep their sampled -values. That is an intervention on the sampled parameter, not a conditional of -the measure on it; with correlated parameters the resulting band can be wider or -narrower than the unimposed one. A scenario `scale` on a varying column -**rescales** it: the variation is kept and multiplied. +An **imposition** is a value set on a varying sampled column by a scenario `set` +or a `--param`. Every sample then runs at that value while its other parameters +keep their sampled values. A design coordinate on a varying sampled column has +the same effect but is recorded separately, as a **design shadow** (§8). That is +an intervention on the sampled parameter, not a conditional of the measure on +it; with correlated parameters the resulting band can be wider or narrower than +the unimposed one. A scenario `scale` on a varying column **rescales** it: the +variation is kept and multiplied. ## 3. The running example @@ -153,14 +154,16 @@ and 0.60. Every other number in §5 is written `…` rather than invented. ### 4.1 One field carries two meanings, and the cell does not say which `CellSpec.point_overrides` (`engine.rs:81`, verified) is the cell's parameter -point. When the job's `ParamSource` is `Sweep` (`sim_job.rs:175`), each point is +point. When the job's `ParamSource` is `Sweep` (`sim_job.rs:179`), each point is a different model — a partition coordinate. When it is `Draws` -(`sim_job.rs:178-192`), each row samples one model — a band coordinate. The same +(`sim_job.rs:182-196`), each row samples one model — a band coordinate. The same loop in `plan_grid` (`engine.rs:168-191`, verified) stamps both into the same field. No function taking only a `&CellSpec` can compute a correct grouping key. -The user-visible consequence, measured on `main`: a three-row file of `beta` -values 0.15, 0.30, 0.60 passed as `--draws grid.tsv --quantities-out q` writes +The user-visible consequence, measured on `main`: a three-row file `grid.tsv` +with columns `draw` and `beta` (`beta` = 0.15, 0.30, 0.60; the draws reader +requires at least two columns, `main.rs:4472-4474`, and takes `draw` as a row +key) passed as `--draws grid.tsv --quantities-out q` writes ``` n_draws q05 q25 q50 q75 q95 @@ -178,8 +181,8 @@ conditions; it describes no distribution anyone stated. parameters in every posterior draw row (`predict.rs:2235-2237`) and runs one `ParamSource::Draws` job per scenario (`predict.rs:2254`, `:2262`). The sweep value lives in the loop variable of `for sweep_pt in &sweep_points` -(`predict.rs:2188`), reaches the renderer directly (`predict.rs:2297-2300`), and -keys `FreeForwardCell` (`predict.rs:2340-2342`). Every predict cell looks like a +(`predict.rs:2188`), reaches the renderer directly (`predict.rs:2300-2303`), and +keys `FreeForwardCell` (`predict.rs:2343-2345`). Every predict cell looks like a posterior draw; its partition coordinate is not in the cell, and its collision check runs separately (`predict.rs:1623-1649`, verified), whose comment says so: "predict folds the sweep into the draw rows, which the engine sees as generated @@ -203,7 +206,7 @@ It was rejected on four grounds (recorded in `2026-08-11` §4): …)` takes the key from its caller, so a caller passing one key for every cell pools exactly as before. The shipped alternative — an accumulator whose only input is a whole cell, deriving its own key - (`main.rs:2464`, verified) — gives the caller no key to pass. + (`main.rs:2485`, verified) — gives the caller no key to pass. 2. **`BandKey::of(&CellSpec)` cannot serve `fit predict`** (§4.2), so a second public constructor would be needed. 3. **`PointKind` names the wrong property.** It records which CLI flag produced @@ -219,8 +222,8 @@ gh#572 (a scenario silently overriding a swept or file-supplied parameter) is fixed on `main`. `engine::check_scenario_coordinate_collision` (`engine.rs:364-390`, verified; signature `-> Result<(), String>`) is called from three sites: `run_job` (`engine.rs:203`), `batch::batch_job` -(`batch.rs:605`, so dry-run and status refuse too), and `simulate --dry-run` -(`main.rs:1799`). Its wording comes from one formatter, +(`batch.rs:607`, so dry-run and status refuse too), and `simulate --dry-run` +(`main.rs:1820`). Its wording comes from one formatter, `params_resolver::scenario_coordinate_collision` (`params_resolver.rs:842-940`), which `fit predict` also calls (`predict.rs:1637`). The policy is the maintainer's: a scenario touching a _user-authored_ coordinate — a sweep, a @@ -233,7 +236,7 @@ checked, everything else passes. So the correct policy is keyed on the proxy reason 3 rejects, and it inherits the proxy's blind spots: a `[design.NAME]` block is reported as "the sweep" (the noun is chosen at `params_resolver.rs:860`, and the design path builds a `Sweep`, -`batch.rs:1839`), and predict's sweep needs its own call because the engine +`batch.rs:1841`), and predict's sweep needs its own call because the engine cannot see it. The existing test for the design-block refusal (`tests/scenario_coordinate_collision_gh572.rs:235-264`) pins the "sweep" wording. This proposal keeps the policy and replaces the key (§8). @@ -316,7 +319,7 @@ parameters.gamma = { range = { min = 0.02, max = 0.5 } } ``` It writes `designs/wide/parameter_points.tsv` (a leading `point_id` column, -`batch.rs:1853-1859`, verified) and one store leaf per point; `batch run` emits +`batch.rs:1855-1861`, verified) and one store leaf per point; `batch run` emits no quantities. After, `parameter_points.tsv` is written at round-trip precision. To get quantities, simulate the generated table as a design: @@ -333,10 +336,10 @@ point_id sweep:beta sweep:gamma value (64 rows; `point_id` is taken from the file, §12.1.) This **recomputes** every point: `simulate` does not look up the `batch run` leaves, and its seeds differ -from the manifest's explicit seeds unless the same `--seed` is given (§7.3). -Collisions: a scenario touching `beta` or `gamma` is refused, naming "the design -`wide`" from `batch run` and "the sweep file `parameter_points.tsv`" from -`simulate`. +from the manifest's explicit seeds unless the same `--seeds` list is given +(§7.3). Collisions: a scenario touching `beta` or `gamma` is refused, naming +"the design `wide`" from `batch run` and "the sweep file `parameter_points.tsv`" +from `simulate`. ### W3 — Prior predictive and uniform exploration: what does the model predict before seeing data? @@ -360,7 +363,7 @@ Manifest `summary` for `--draws prior`: { "over": ["sample"], "measure": "prior", - "measure_source": { "kind": "model_ir" }, + "measure_source": { "kind": "model_ir", "ir_hash": "…" }, "varying": ["beta", "gamma"], "n_samples": 500, "replicates_per_sample": 1, @@ -380,9 +383,10 @@ camdl simulate sir.camdl --sweep-file u.tsv --backend ode --quantities-out q ``` giving `point_id sweep:beta sweep:gamma value`, 500 rows. This works because -`--draws-out` writes only the varying columns once gh#949 lands; a constant -column in a sweep file is refused (§12.1), so an export that still carried -constants would say so rather than silently make every parameter a coordinate. +a `uniform` (or `prior`) export carries only the varying columns, since gh#949. +A constant column in a sweep file of two or more rows is refused (§12.1), so a +file that did carry constants would say so rather than silently make every +parameter a coordinate. **Collisions.** `--scenario pinned` over the prior imposes `beta`. It runs, with one stderr line: @@ -437,8 +441,10 @@ camdl simulate sir.camdl --draws post.tsv --scenario distancing ... The second command is refused, because `distancing` scales the file's `beta` column and a draws file is a user-authored coordinate (§4.4). -**After**, `--draws-out` writes `post.tsv` and a provenance sidecar -`post.tsv.json` (§12.4), and prints the round-trip hint: +**After**, `--draws-out` writes `post.tsv` — with today's columns for a +posterior, the estimated parameters and the fit's `[fixed]` columns — and a +provenance sidecar `post.tsv.json` (§12.4) whose `varying` lists only the +estimated set. It prints the round-trip hint: ``` draws.tsv: wrote 200 draws to post.tsv (+ post.tsv.json); reuse as a sample with --draws-file post.tsv @@ -449,14 +455,19 @@ camdl simulate sir.camdl --draws-file post.tsv --scenario distancing \ --backend ode --quantities-out q ``` -runs. The sidecar carries the measure through: `band` is `posterior`, not -`asserted`, and `measure_source` names the original fit. Without a sidecar the -same file reads as `band = asserted`, +runs. The `[fixed]` columns travel in the file, so the reused posterior needs no +`--fit` to run its pinned parameters at the fitted values rather than at model +defaults — the same reason `fit predict` relies on the posterior rows carrying +them (`predict.rs:2283-2285`). The sidecar carries the measure through: `band` +is `posterior`, not `asserted`, and `measure_source` names the original fit; +`varying` is the estimated set, so a scenario over `N0` (a `[fixed]` column) is +not an imposition. Without a sidecar the same file reads as `band = asserted`, `measure_source: {"kind": "file", "sha256": "…"}`. Passing `post.tsv` as `--sweep-file` with the same scenario is refused, naming "the sweep file -`post.tsv`"; without a scenario it is refused anyway if the file carries -`chain`/`draw` columns, with a hint to use `--draws-file` (§12.1). +`post.tsv`"; without a scenario it is refused anyway, because the file's +`[fixed]` columns are constant (§12.1) and, if present, because of its +`chain`/`draw` columns, with a hint to use `--draws-file`. ### W6 — `fit predict` over a sweep, the posterior, and scenarios @@ -465,7 +476,7 @@ camdl fit predict @sir-fit --sweep gamma=0.08,0.12 --scenario distancing ``` **Today**, `/quantities/peak_I.tsv` is design-major — sweep point outer, -scenario inner (`predict.rs:2188` then `:2295`), which `StackedQuantities` +scenario inner (`predict.rs:2188` then `:2298`), which `StackedQuantities` preserves because it does not sort: ``` @@ -489,13 +500,13 @@ distancing 1 0.12 posterior 200 … … … … **After Stage 7** (the sweep moves onto the grid) the bytes are identical to those of Stage 7's parent commit: rows stay design-major by explicit rendering -order. **After the commit that follows Stage 7's A/B**, predict's summary -records the design's imposition on the posterior: `"varying"` lists the fit's -estimated parameters, `"design_shadows": ["gamma"]`, and `measure` reads -`"posterior"` with +order. Predict's summary carries `"varying"` (the fit's estimated parameters) +from Stage 4. **After the commit that follows Stage 7's A/B**, it also records +the design's shadow on the posterior: `"design_shadows": ["gamma"]`, and +`measure` reads `"posterior"` with `"measure_note": "posterior of the other parameters with gamma imposed by the design"`; -one stderr note says the same. Stage 4 does not emit these three fields for +one stderr note says the same. Stage 4 does not emit these two fields for predict, because predict's sweep is not yet a design axis there. `--sweep beta=… --scenario distancing` is refused before any artifact is written (§14, Stage 7), naming "the sweep". @@ -530,7 +541,7 @@ not delivered, because contrasts fork each draw from its _smoothed_ state, inferred under the fitted parameters; a sweep point replaces a parameter, so no smoothed state exists for it — the same reason `conditioned_here` refuses a conditioned read for any swept cell (`predict.rs:934-943`, verified). -`emit_contrasts` receives no sweep (`predict.rs:2731-2739`, verified), so +`emit_contrasts` receives no sweep (`predict.rs:2734-2742`, verified), so contrasts are emitted once, for the un-swept model. After, `fit predict` says so: a stderr note (`contrasts are computed for the fitted model only; --sweep is @@ -563,7 +574,7 @@ trajectory. A pure-state quantity (`peak_I`, `final_R`) then has 50 bit-identical realizations in its group and renders in point mode — one `value` — with `"over": []`, rather than `n_draws = 50` and `q05 = q95`. A quantity that reads `observations.` still varies across replicates, because each -replicate's observation draw uses its own seed (`main.rs:2447-2460`), so its +replicate's observation draw uses its own seed (`main.rs:2468-2481`), so its realizations differ and it stays banded with `"over": ["replicate"]`. The same data rule covers `--init-state FILE` under ODE: replicate `i` restores row `i` of the state file (`engine.rs:452-455`), so realizations differ and the quantity @@ -630,19 +641,24 @@ of `scale` with a sweep is follow-up F1. `--design-from fit.toml` writes a dataset — one file per observation stream on the fit's own observed rows — at each parameter vector; with `--draws` it writes -one `ds_NN/` per row (`args/mod.rs:470-489`; `simulate_on_bound_design`, -`main.rs:3248`, which builds its own `SimRun`s and seeds at -`main.rs:3275-3276`). It bypasses the engine, and it ignores `--scenario` +one `ds_NN/` per row (`args/mod.rs:474-493`; `simulate_on_bound_design`, +`main.rs:3269`, which builds its own `SimRun`s and seeds at +`main.rs:3296-3297`). It bypasses the engine, and it ignores `--scenario` entirely (gh#948, a separate defect). **After**, it accepts every parameter source — `--draws posterior|prior|uniform`, `--draws-file`, `--sweep-file`, `--sweep` — -and still writes one dataset per parameter point, because it never reduces. With -`--draws prior`, the priors and the `[fixed]` block come from the design -`fit.toml` itself; a `--fit` naming a different file is refused. Beside the -datasets it writes `truths.tsv` — one row per dataset: `dataset`, `point_id` -(design) or `sample_idx` (sample), then every parameter at round-trip precision -— and `design_from.json`: +and still writes one dataset per parameter point, because it never reduces. Its +seeds are a stated exception to D-B: each dataset is seeded by its **dataset +index**, exactly as today (`engine::process_seed_for(None, seed, i, 0, n)`, +`main.rs:3296-3297`), for a design as for a sample. The datasets are independent +replicates of a self-consistency experiment, not paired conditions, so sharing +process noise across them would correlate what the downstream analysis treats as +independent fits. With `--draws prior`, the priors and the `[fixed]` block come +from the design `fit.toml` itself; a `--fit` naming a different file is refused. +Beside the datasets it writes `truths.tsv` — one row per dataset: `dataset`, +`point_id` (design) or `sample_idx` (sample), then every parameter at round-trip +precision — and `design_from.json`: | Field | Type | Meaning | | ---------------- | -------------------- | -------------------------------------------------------------------------- | @@ -667,7 +683,7 @@ camdl simulate sir.camdl --sweep beta=0.15,0.3,0.6 --param beta=0.5 ... **Today** this cannot be written in `simulate` (no `--sweep`); with a draws file the analogue runs, because `--param` sits at tier 5 above the draw/sweep tier -(`util.rs:3244-3253`) and wins silently: three identical runs labelled with +(`util.rs:3280-3290`) and wins silently: three identical runs labelled with three values. **After**, `--param` and expanded `--param-vec` entries are part of the collision footprint (§8): on a design name they are refused — @@ -690,21 +706,26 @@ camdl simulate sir.camdl --fit results/fits/sir-8a3f12b4 --draws posterior \ camdl simulate sir.camdl --draws-file post.tsv -n 200 --quantities-out q ``` -The first command writes `post.tsv` (varying columns only) and `post.tsv.json`. -The second reads both: `-n 200` subsamples the file strided across all rows -through the same `subsample_draws` as `--draws posterior`, and the seed index of -each kept row is its position in the subsample (0…199), as for -`--draws -posterior`. Without `-n` every row runs, at the row index as seed index -— there is no default cap, so `--draws-file` keeps today's `--draws FILE` seeds -exactly; a file above 1000 rows prints a warning suggesting `-n` (today `-n` is -ignored for a file, so a raw 60k-row `draws.tsv` replays every row silently — -the gh#630 hazard). `band` is `posterior`; the summary's `n_samples` is 200 and -`measure_source` names the original fit and records -`"subsample": {"from": 1000, "stride": 5}`. If `sir.camdl` has changed since the -export, the IR hash in the sidecar differs and a warning says so; parameters the -file does not carry are listed in `"from_outside_file": [...]` with where their -values came from (`--params`, `--fit` `[fixed]`, model default). +The first command writes `post.tsv` (the estimated parameters and the fit's +`[fixed]` columns, as today) and `post.tsv.json` (whose `varying` is the +estimated set, and whose `subsample` is `{"from": , "n": 1000}`, +because `-n 1000` itself subsampled the posterior cloud). The second reads both: +`-n 200` subsamples the file strided across all rows through the same +`subsample_draws` as `--draws posterior`, and the seed index of each kept row is +its position in the subsample (0…199), as for `--draws +posterior`. Without `-n` +every row runs, at the row index as seed index — there is no default cap, so +`--draws-file` keeps today's `--draws FILE` seeds exactly; a file above 1000 +rows prints a warning suggesting `-n` (today `-n` is ignored for a file, so a +raw 60k-row `draws.tsv` replays every row silently — the gh#630 hazard). `band` +is `posterior`; the summary's `n_samples` is 200 and `measure_source` names the +original fit and records the subsample chain, +`"subsample": [{"from": , "n": 1000}, {"from": 1000, "n": 200}]`: +`-n` on a file whose sidecar is already subsampled subsamples again from the +file's rows, and the record keeps both steps. If `sir.camdl` has changed since +the export, the IR hash in the sidecar differs and a warning says so; parameters +the file does not carry are listed in `"from_outside_file": [...]` with where +their values came from (`--params`, `--fit` `[fixed]`, model default). ## 6. The rule: design coordinates condition, samples are marginalized @@ -732,20 +753,20 @@ records). `--draws uniform` is an iid sample, not a space-filling design: `generate_uniform_draws` draws `lo + (hi − lo)·U` independently per parameter -per row (`main.rs:3860-3880`, verified), holding unbounded parameters at their -default. The run spec calls it "space-filling exploration" -(`docs/camdl-run-spec.md:1145-1148`); Stage 5 corrects that sentence. The actual -space-filling designs (`[design.NAME]`, `method = "sobol" | "lhs" | -"random"`, -`batch.rs:98-107`) already run as `ParamSource::Sweep` (`batch.rs:1839`) — -reason 3 in running code. +per row (`main.rs:3997-4021`, verified); since gh#949 a row carries only the +bounded parameters it draws, and the rest resolve per cell. The run spec calls +it "space-filling exploration" (`docs/camdl-run-spec.md:1150-1154`); Stage 5 +corrects that sentence. The actual space-filling designs (`[design.NAME]`, +`method = "sobol" | "lhs" | +"random"`, `batch.rs:98-107`) already run as +`ParamSource::Sweep` (`batch.rs:1841`) — reason 3 in running code. ### 6.2 What each kind implies for the three decisions | Decision | Design coordinate | Varying sampled column | | -------------------------------------- | ------------------------------------------------------ | ----------------------------------------------------------------------------- | | A scenario or `--param` sets/scales it | Contradiction — refused, naming the design's origin | Imposition — allowed; recorded in `summary.impositions`, stderr note on `set` | -| A design coordinate sets it | — | Imposition — allowed; recorded in `summary.design_shadows`, stderr note | +| A design coordinate sets it | — | Design shadow — allowed; recorded in `summary.design_shadows`, stderr note | | Labels and provenance | `point_id` + `sweep:` columns, manifest `sweep` | Not per row; `band` column and manifest name the measure | | Marginal reduction (quantile, mean) | Never crosses it | Crosses it | | Paired reduction | Crosses it, matched on the band coordinate | Supplies the matching coordinate | @@ -840,6 +861,15 @@ pub enum DesignOrigin { File { path: PathBuf }, // --sweep-file FILE: "the sweep file `FILE`" } +/// One row of an already-expanded design: optional id and label (from a +/// `--sweep-file`), and the coordinate values by name. +#[derive(Debug, Clone)] +pub struct DesignRow { + point_id: Option, + label: Option, + values: IndexMap, +} + /// Exchangeable draws from a stated measure. A band axis. #[derive(Debug, Clone)] pub enum SampleAxis { @@ -849,19 +879,69 @@ pub enum SampleAxis { measure: Measure, /// The parameters the measure varies (§2), computed at construction. varying: Vec, + provenance: SampleProvenance, }, } +/// Where a sample's rows and its non-row parameters came from. +#[derive(Debug, Clone, PartialEq)] +pub struct SampleProvenance { + /// Each subsampling step applied, oldest first (§12.4). + subsample: Vec, + /// The file the rows were read from, for `--draws-file`. + file: Option, + /// Parameters not carried by the rows, and the tier that supplied each. + from_outside: Vec<(String, Origin)>, + /// Whether `varying` came from the measure's own list or from the rows. + varying_from: VaryingFrom, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Subsample { from: usize, n: usize } + +#[derive(Debug, Clone, PartialEq)] +pub struct FileRef { path: PathBuf, sha256: String } + +#[derive(Debug, Clone, PartialEq)] +pub enum Origin { Params, FitFixed, Scenario(String), Param, ModelDefault } + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum VaryingFrom { Estimated, Prior, Bounds, Sidecar, Columns } + /// What the sample is a sample of. #[derive(Debug, Clone, PartialEq)] pub enum Measure { - Posterior { fit: FitRef, method: String }, // fit handle + run id + stage - Prior { source: PriorSource }, // model IR or fit.toml (+ content hash) - UniformBounds, // lo + (hi-lo)·U on declared bounds - Asserted { path: PathBuf, sha256: String }, // --draws-file FILE, no sidecar + Posterior { fit: FitRef, method: String }, + Prior { source: PriorSource }, + UniformBounds, // lo + (hi-lo)·U on declared bounds + Asserted, // --draws-file FILE with no sidecar; the file is in provenance +} + +/// A fit, named by handle and content hash — never by filesystem path alone. +#[derive(Debug, Clone, PartialEq)] +pub struct FitRef { handle: Option, run_id: runid::RunId } + +#[derive(Debug, Clone, PartialEq)] +pub enum PriorSource { + ModelIr { ir_hash: String }, + FitToml { path_sha256: String }, } ``` +The replicate count moves into the S layer. `Seeds` (`sim_job.rs`) becomes + +```rust +pub enum Seeds { + /// A base seed and a replicate count; replicates derive seeds by mixing. + Single { base: u64, replicates: usize }, + /// An explicit list; each seed is one replicate slot, used verbatim. + Explicit(Vec), +} +impl Seeds { pub fn replicates(&self) -> usize; } // explicit length, else `replicates` +``` + +so the count is stored once, where `effective_replicates` already looks for it. + `DesignMethod` carries the block name and the method (`sobol | lhs | random`). Fields are private to `sim_job`; the constructors are the only way in, each validating its invariant: @@ -886,9 +966,10 @@ impl DesignAxis { impl SampleAxis { /// Refuses an empty row set. `varying` is supplied by the source (the fit's /// estimated set, the parameters with a prior, the bounded parameters, the - /// sidecar's list) or computed as the non-constant columns of a file. - pub fn draws(rows: Vec>, measure: Measure, varying: Vec) - -> Result; + /// sidecar's list) or computed as the non-constant columns of a file, and + /// `provenance.varying_from` says which. + pub fn draws(rows: Vec>, measure: Measure, varying: Vec, + provenance: SampleProvenance) -> Result; } ``` @@ -904,14 +985,18 @@ shadow (§6.2). §7.4 makes the precedence a resolver tier. ### 7.2 Varying columns are computed once, at the boundary -Today every generated row carries every parameter: `uniform` fills unbounded -parameters with their default (`main.rs:3874-3890`); `prior` fills parameters -without a prior with their default or a scenario's value (`main.rs:4131-4143`, -`:4190-4204`); a posterior `draws.tsv` carries the fit's `[fixed]` columns. -gh#949 stops generated rows from carrying what the measure does not sample. -`varying` is the list this proposal reads, wherever it matters: the collision -recording (§8), the stderr note, `summary.varying`, and `summary.over` (a -scenario that sets every varying column leaves nothing to pool over the sample). +Since gh#949, `prior` rows carry only the parameters with a prior and `uniform` +rows only the bounded parameters drawn (`generate_uniform_draws`, +`main.rs:3997-4021`); the `prior --fit FIT.toml` path routes the config's +`[fixed]` block to the resolver's tier 2 through `SimRun::fit_fixed` +(`sim_job.rs:104`, `engine.rs:470`) instead of into the rows. Only posterior +rows still carry constants: a `draws.tsv` carries the fit's `[fixed]` columns, +and `fit predict` relies on them (`predict.rs:2283-2285`). gh#949 also added +`GeneratedMeasure` (`main.rs:3882-3888`), a two-variant proto-measure for its +pre-flight, which `Measure` subsumes. `varying` is the list this proposal reads, +wherever it matters: the collision recording (§8), the stderr note, +`summary.varying`, and `summary.over` (a scenario that sets every varying column +leaves nothing to pool over the sample). | Measure | `varying` | | --------------- | ------------------------------------------------------------------- | @@ -959,25 +1044,27 @@ index). Consequences, job by job: the seeds of today's `--draws FILE`: it has no default row cap, so without `-n` every row runs at its row index. With `-n`, the file is subsampled and the seed index is the row's position in the subsample, as for - `--draws - posterior` today. + `--draws posterior` today. - `--sweep` and `--sweep-file` are new; they have no runs to preserve. Every design point uses `seed_index = 0`, so inserting or removing a point leaves the other points' trajectories unchanged. - The same file passed as `--sweep-file` and as `--draws-file` runs at different seeds (0 for every row vs the row index), so the two do not share store leaves beyond row 0; their ensembles are keyed apart as well (§7.6). +- `--design-from` is the exception: it keeps seeding each dataset by its dataset + index (`main.rs:3296-3297`), for a design as for a sample, because its + datasets are independent replicates, not paired conditions (W11). - `fit predict --sweep` after Stage 7: sample index = draw index, as inside today's per-sweep-point jobs. The `total_runs == 1` branch (`engine.rs:61-62`) no longer fires on the combined job, but `mix_cell_seed(base, 0, 0) == base` - (`util.rs:35-37`; asserted at `engine.rs:520`), so the seed is unchanged. + (`util.rs:35-37`; asserted at `engine.rs:521`), so the seed is unchanged. `seed_index` also replaces `point_idx` at the per-draw lookups that follow the sample: the `--init-state fit` row (`engine.rs:452-455`), `ConditionedSource::per_draw` (`predict.rs:1187`), `chain_of_point` -(`predict.rs:1262`), and `RunEntry.draw_idx` (`batch.rs:1498`, `:1707`), which +(`predict.rs:1262`), and `RunEntry.draw_idx` (`batch.rs:1500`, `:1709`), which feeds the `SimEnsemble` grid digest (`sim_ensemble_cas.rs:19-24`, `:120-133`, -`:192`; `main.rs:2680`). +`:192`; `main.rs:2701`). ### 7.4 Resolver tier 3.5 splits in two, design over sample @@ -995,17 +1082,20 @@ resolves with `point_overrides: &[]` (§6.4), so `parameters_provenance` never holds `sweep_point`. The tag surfaces only in error text: `UnknownParameter` prints `from SweepPoint` via `Debug` (`params_resolver.rs:375-381`), and `NonFiniteValue` suggests `--fixed` (`:393-400`), which `simulate` users spell -`--param`. Stage 8 rewrites both: -`column`betta`of`post.tsv`is not a -parameter of sir.camdl`, and a fix hint -naming the flag the command actually has. +`--param`. Stage 8 rewrites both — the first to "column `betta` of `post.tsv` is +not a parameter of sir.camdl", the second to a fix hint naming the flag the +command actually has. `SimRun` has construction sites beyond `build_cell_sim_run`: `main.rs:1236` (the -base run), `main.rs:3275` (`--design-from`), `util.rs:3166`, -`util.rs:3259-3285`, `util.rs:4427`, and the contrasts arm resolver passes draw -rows at tier 3.5 (`contrasts.rs:643`). Each is classified in Stage 2; the -exhaustive destructure at `batch.rs:2888` makes the compiler list any that is -missed. +base run), `main.rs:3296` (`--design-from`), `util.rs:3173`, +`util.rs:3296-3322`, `util.rs:4464`, and the contrasts arm resolver passes draw +rows at tier 3.5 (`contrasts.rs:643`). gh#949 added a `fit_fixed` field to +`SimRun` and `SimulateJob` (`util.rs:2694`, `sim_job.rs:104`) and a per-arm +pre-flight, `refuse_unresolvable_generated_cells` (`main.rs:3909`, called at +`:1796`), which resolves every arm of a generated-draws job before any cell +runs; it builds its own resolver inputs and is classified with the rest. Each is +classified in Stage 2; the exhaustive destructure at `batch.rs:2890` makes the +compiler list any that is missed. ### 7.5 The cell: coordinates replace `point_idx` and `point_overrides` @@ -1055,10 +1145,10 @@ pub struct Grid { `CellSpec`'s fields become private to `engine`, read through accessors, and `plan_grid` (`engine.rs:148-193`) is the single constructor. `cli` is a binary -crate, so module privacy is enforceable; test fixtures (`batch.rs:2771`, -`:2849`) use a `#[cfg(test)]` constructor in `engine`. The one non-test +crate, so module privacy is enforceable; test fixtures (`batch.rs:2773`, +`:2851`) use a `#[cfg(test)]` constructor in `engine`. The one non-test constructor outside `plan_grid` is `CasSink::predict_cells` -(`batch.rs:1272-1307`, verified), which re-implements the grid loop for dry-run +(`batch.rs:1274-1309`, verified), which re-implements the grid loop for dry-run cache classification; it calls `plan_grid` instead (Stage 0). `plan_grid` iterates `scenario → design point → sample → replicate`; with either axis `Unit` this is today's `scenario → point → rep`. `Grid` (`engine.rs:105-111`) gains the @@ -1069,19 +1159,17 @@ per-axis sizes the banner, the dry run and the combined writers need (§12.3, | Front end | Today | After | | ------------------------- | ------------------------------------------------------------------ | ------------------------------------------------------------------------------- | -| `simulate --draws …` | `main.rs:1735-1751`: `Draws { explicit_file }` or `Point` | `SampleAxis::draws(rows, Posterior \| Prior \| UniformBounds, varying)` | +| `simulate --draws …` | `main.rs:1725-1741`: `Draws { explicit_file }` or `Point` | `SampleAxis::draws(rows, Posterior \| Prior \| UniformBounds, varying)` | | `simulate --draws-file` | (was `--draws FILE`) | `SampleAxis::draws(rows, sidecar measure \| Asserted, varying)` | | `simulate --sweep-file` | — | `DesignAxis::from_rows(rows, File { path })` (reader §12.1) | | `simulate --sweep` | — | `DesignAxis::sweep(&specs)` | -| `batch run [sweep]` | `batch.rs:629-644` (`sweep_source`): `Sweep` or `Point` | `DesignAxis::from_rows(points, Sweep)` | -| `batch run [design.NAME]` | `batch.rs:1839`: `Sweep` | `DesignAxis::from_rows(points, Design { method })` | +| `batch run [sweep]` | `batch.rs:631-646` (`sweep_source`): `Sweep` or `Point` | `DesignAxis::from_rows(points, Sweep)` | +| `batch run [design.NAME]` | `batch.rs:1841`: `Sweep` | `DesignAxis::from_rows(points, Design { method })` | | `fit predict` | `predict.rs:2254`: `Draws { explicit_file: None }` per sweep point | one plan: `DesignAxis::sweep(&args.sweep)` × `SampleAxis::draws(.., Posterior)` | `simulate --sweep` reuses the CLI grammar already shared by `fit run`, -`fit -predict` and `profile`: `args::types::SweepSpec` (`args/types.rs:255`, -`V1,V2,… -| lin(min,max,n) | log10(min,max,n)`). Predict's expansion, +`fit predict` and `profile`: `args::types::SweepSpec` (`args/types.rs:255`; a +value list, `lin(min,max,n)` or `log10(min,max,n)`). Predict's expansion, `expand_predict_sweep` (`predict.rs:1359-1380`, Cartesian, sorted by name), and its duplicate-name and unknown-name checks (`predict.rs:1594-1621`) move into `DesignAxis::sweep` and `ParamPlan::new`, so both verbs expand and validate @@ -1089,19 +1177,19 @@ through one function. The batch TOML sweep grammar (`batch.rs:143`, `linspace`/`logspace`/`range`) is a different surface syntax and stays; its expansion (`expand_sweep`, `batch.rs:293`) feeds `DesignAxis::from_rows`. A zipped (non-Cartesian) design is a `--sweep-file`. The `explicit_file` field -(`sim_job.rs:182-191`) is deleted. +(`sim_job.rs:186-195`) is deleted. | Consumer | Reads | Replaces | | -------------------------------------------------------------------------- | ------------------------------------------------------------------ | ------------------------------------------------------------------------------------ | | Collision guard (§8) | plan design names/origin, varying, `fixed_cli` | `ParamSource` match at `engine.rs:367-375`; predict's call at `predict.rs:1623-1649` | -| `SimQuantities::push_cell` (`main.rs:2464`) | `cell.spec.coords().partition_key()` | `cell.spec.scenario.name()` | +| `SimQuantities::push_cell` (`main.rs:2485`) | `cell.spec.coords().partition_key()` | `cell.spec.scenario.name()` | | `PredictiveSink::merge_cell` (`predict.rs:1127`) | same | same, plus the enclosing `sweep_pt` loop variable | -| `FreeForwardCell`, `assemble_predictive` (`predict.rs:2340`, `:2489-2493`) | `PartitionKey` + `DesignPoint` | the `sweep_pt` loop variable | -| Quantity mode (`main.rs:1931-1940`) | per-quantity rule (§10) | `matches!(source, Point { replicates: 1 })` | -| `CasSink::cell_resolve` label (`batch.rs:1150`) | design names ∪ varying columns | `point_overrides.keys()` | +| `FreeForwardCell`, `assemble_predictive` (`predict.rs:2343`, `:2492-2496`) | `PartitionKey` + `DesignPoint` | the `sweep_pt` loop variable | +| Quantity mode (`main.rs:1952-1961`) | per-quantity rule (§10) | `matches!(source, Point { replicates: 1 })` | +| `CasSink::cell_resolve` label (`batch.rs:1152`) | design names ∪ varying columns | `point_overrides.keys()` | | Seed and per-draw lookups (§7.3) | `coords.seed_index()` | `point_idx` | | `RunEntry.draw_idx` → `SimEnsemble` grid (§7.3) | sample index; axis kind folded into the grid level for design jobs | `point_idx` | -| Wide trajectory / obs writers (`main.rs:2894`, `:2931`, `:3060`, `:3094`) | `CellCoords`, `Grid` (§9.2) | `draw = point_idx + 1`, `n_draws = grid.n_points` | +| Wide trajectory / obs writers (`main.rs:2915`, `:2952`, `:3081`, `:3115`) | `CellCoords`, `Grid` (§9.2) | `draw = point_idx + 1`, `n_draws = grid.n_points` | | Contrasts arm resolver (`contrasts.rs:643`) | sample row at tier 3.5a | draw row at tier 3.5 | The `SimEnsemble` grid level folds the axis kind for design jobs so that @@ -1110,28 +1198,36 @@ different bytes (the two write different combined-file columns, §9.2). The fold is an optional field of `EnsembleGridLevel` (`sim_ensemble_cas.rs:101`, built at `:132`), serialized only when present (`skip_serializing_if = "Option::is_none"`) and set only for jobs with a design -axis. Every job that exists today has none, so no existing ensemble grid hash -changes; Stage 5 tests both halves — a `--draws uniform` ensemble's grid-level -hash equals its value on the parent commit, and a `--sweep-file X` and a -`--draws-file X` ensemble over the same file have different grid-level hashes. +axis. `batch run` jobs have design axes but write no ensemble: ensembles are +built only on the `simulate` path (`write_sim_ensemble`, `main.rs:2685`, the +cell list at `:2694-2701`), and no `simulate` job that exists today has a design +axis, so no existing ensemble grid hash changes; Stage 5 tests both halves — a +`--draws uniform` ensemble's grid-level hash equals its value on the parent +commit, and a `--sweep-file X` and a `--draws-file X` ensemble over the same +file have different grid-level hashes. ## 8. Collision policy keys on the coordinate kind `check_scenario_coordinate_collision` keeps its name and its three call sites -(§4.4), plus the `--event-log` path, which calls `plan_grid` directly -(`main.rs:1978`) and so bypasses the guard today. Its signature changes from -`Result<(), String>` to +(§4.4). The `--event-log` path calls `plan_grid` directly (`main.rs:1999`) but +is single-run only, so it has no design axis and at most one row; routing it +through the guard is a harmless consistency call, not a fix. The signature +changes from `Result<(), String>` to ```rust +pub struct GuardFindings { + pub impositions: Vec, + pub design_shadows: Vec, +} pub fn check_scenario_coordinate_collision(job: &SimulateJob) - -> Result, String> + -> Result ``` The footprint of each overriding source is intersected with the plan: - **Sources:** each scenario's `scenario_param_footprint` (`params_resolver.rs:698-733`), and the job's tier-5 entries — `--param` and - the expanded `--param-vec` names (`util.rs:3244-3253`, the list the resolver + the expanded `--param-vec` names (`util.rs:3280-3290`, the list the resolver applies at tier 5). - **Against design names** — non-empty is a hard error. `UserCoordinate` (`params_resolver.rs:806`) becomes a view of `DesignOrigin` plus the tier-5 @@ -1143,8 +1239,9 @@ The footprint of each overriding source is intersected with the plan: `Imposition { param, by: Scenario(name) | FixedCli, action: Set | Scale }`, which the front end prints (W3 wording) and the quantity accumulators copy into `summary.impositions`. -- **Design names against varying sample columns** — allowed; returned as design - shadows (§6.2), printed, and copied into `summary.design_shadows`. +- **Design names against varying sample columns** — allowed; returned in + `GuardFindings::design_shadows` (§6.2), printed, and copied into + `summary.design_shadows`. A design shadow is not an imposition. Notes are printed **once per (source, column) per invocation**, never per cell and never per `run_job` call. The guard returns the list; the verb's front end @@ -1156,7 +1253,7 @@ Because `fit predict --sweep` builds a plan with a design axis after Stage 7, the engine's guard covers it; predict nevertheless keeps an explicit pre-flight call before its free-forward closure, because inside that closure an error is caught and reported as "continuing with what is computable" -(`predict.rs:2377-2382`) while other outputs are still written. The +(`predict.rs:2380-2385`) while other outputs are still written. The footprint∩names rule is exact, not a stopgap: `2026-08-11` §1.1 shows disjointness is required for both distinctness and denotation. @@ -1173,7 +1270,7 @@ holds. | # | Column(s) | Present when | | -- | ------------------------------------- | ------------------------------------------------------------------------------------------- | | 1 | `scenario` | `simulate`: any `--scenario` given; `fit predict`: always | -| 2 | `point_id` | the job has a design axis | +| 2 | `point_id` | the job has a design axis, or `fit predict` has `--sweep` | | 3 | `sweep:` … | the job has a design axis; one per design name, sorted | | 4 | `time` | the quantity is a series | | 5 | `` … | the quantity is stratified | @@ -1209,14 +1306,17 @@ is already `fit predict`'s published header. Rows are ordered design-major: for each design point in axis order, each scenario in `--scenario` order, then time and strata. This is `fit predict`'s -order today (`predict.rs:2188` → `:2295`); `simulate` adopts it. +order today (`predict.rs:2188` → `:2298`). `simulate` adopts it by rendering its +groups **sorted by (design index, `--scenario` position)** explicitly, not in +accumulator insertion order: `plan_grid` iterates scenario-major (§7.5), so +insertion order would be scenario-major. ### 9.2 Combined trajectory and observation files -The wide writer (`main.rs:2884-2900`) writes `replicate` (the global cell +The wide writer (`main.rs:2905-2921`) writes `replicate` (the global cell ordinal `run_idx + 1`, when `total_runs > 1`), `scenario` (when `n_scenarios > 1`), `draw` (when `n_draws > 1`), then time and state columns; -observation rows mirror it (`main.rs:3060`, `:3094`, `:3158`). After: +observation rows mirror it (`main.rs:3081`, `:3115`, `:3179`). After: | Column | Present when | Value | | ----------------- | ------------------------------------- | ------------------------------- | @@ -1227,10 +1327,17 @@ observation rows mirror it (`main.rs:3060`, `:3094`, `:3158`). After: | `draw` | sample axis with more than one row | sample index + 1 | A design job with no sample axis writes no `draw` column; today's `--draws FILE` -output (now `--draws-file`) is byte-identical. +output (now `--draws-file`) is byte-identical. Combined trajectory and +observation rows keep the engine's canonical cell order, `plan_grid`'s +`scenario → design point → sample → replicate` (§7.5), then time; unlike the +quantity files they are not re-sorted, because with no design axis that order is +today's and re-sorting would change today's bytes. ### 9.3 Manifest fields +The manifest's `schema` becomes `camdl.quantities/v2` (`quantity_output.rs:512`, +`:643` write `v1` today): the new keys are additive, but the `band` column +changes the TSV schema the manifest describes. Pre-1.0, no `v1` reader is kept. Every entry of `quantities.json` carries: | Field | Type | Req. | Meaning | @@ -1241,22 +1348,33 @@ Every entry of `quantities.json` carries: | `scenario` | string | when a scenario column exists | this group's scenario (as today) | | `sweep` | object | when a design axis exists | `{param: value}` exact; indexed params also `{family, index}` | | `point_id` | integer | when a design axis exists | this group's design point | +| `label` | string | when the sweep file has one | the design point's `label` from a `--sweep-file` | +| `evaluated_on`, `n_conditioned_draws` | as today | `fit predict` | unchanged (`quantity_output.rs:501-503`, gh#722), below | | `summary` | object | yes | below | `summary`: -| Field | Type | Req. | Meaning | -| ----------------------- | -------------------------------- | ------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `over` | array of `"sample"\|"replicate"` | yes | band axes that actually varied in this group (§10); `[]` in point mode | -| `measure` | string | when `over` is non-empty | `posterior \| prior \| uniform_bounds \| asserted \| process` | -| `measure_note` | string | no | e.g. "posterior of the other parameters with gamma imposed by the design" | -| `measure_source` | object | when `measure` is not `process` | `{"kind":"fit","handle","run_id","method"}`, `{"kind":"model_ir","ir_hash"}`, `{"kind":"fit_toml","path_sha256"}`, `{"kind":"file","sha256"}`, plus `subsample` when strided | -| `varying` | array of string | when a sample axis exists | §7.2 | -| `n_samples` | integer or null | yes | rows on the sample axis; `null` when there is none | -| `n_samples_used` | integer | when rows can be dropped | rows actually reduced (e.g. conditioned subsets) | -| `replicates_per_sample` | integer | yes | derived from `Seeds` (§7.1) | -| `impositions` | array of object | yes | `{param, by, action}` on varying columns (§8); `[]` if none | -| `design_shadows` | array of string | yes | design names that are varying sample columns; `[]` if none | +| Field | Type | Req. | Meaning | +| ----------------------- | -------------------------------- | ------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `over` | array of `"sample"\|"replicate"` | yes | band axes that actually varied in this group (§10); `[]` in point mode | +| `measure` | string | when `over` is non-empty | `posterior \| prior \| uniform_bounds \| asserted \| process` | +| `measure_note` | string | no | e.g. "posterior of the other parameters with gamma imposed by the design" | +| `measure_source` | object | when `measure` is present and not `process` | `{"kind":"fit","handle","run_id","method"}`, `{"kind":"model_ir","ir_hash"}`, `{"kind":"fit_toml","path_sha256"}`, `{"kind":"file","sha256"}`; plus `subsample`, a list of `{"from": N, "n": n}` steps, when subsampled | +| `varying` | array of string | when a sample axis exists | §7.2 | +| `varying_from` | string | when a sample axis exists | `estimated \| prior \| bounds \| sidecar \| columns` — where `varying` came from | +| `from_outside_file` | array of `{param, origin}` | `--draws-file` | parameters the file does not carry, and the tier that supplied each | +| `n_samples` | integer or null | yes | rows on the sample axis; `null` when there is none | +| `n_samples_used` | integer | when rows can be dropped | samples whose realization entered the band — fewer than `n_samples` when a sample's quantity is non-finite or refused | +| `replicates_per_sample` | integer | yes | derived from `Seeds` (§7.1) | +| `impositions` | array of object | yes | `{param, by, action}` on varying columns (§8); `[]` if none | +| `design_shadows` | array of string | yes | design names that are varying sample columns; `[]` if none | + +`n_samples_used` and `n_conditioned_draws` are different counts. +`n_conditioned_draws` (gh#722) is the number of draws whose saved smoothing path +was opened for a conditioned `value_at` read — a statement about _which object_ +the numbers come from; it appears only on a `smoothed` entry. `n_samples_used` +is the number of samples that contributed a value to the band at all; it appears +wherever rows can drop out. `measure_source` records a fit's handle, content hash (`run_id`) and method, never a filesystem path, so a manifest stays valid when results move. The field @@ -1287,19 +1405,22 @@ For each quantity and each group, after all cells are merged: - the group is **constant** when every realization is bit-identical (compared on the `f64` bit patterns, a censored value equal only to a censored value); -- the group's **attribution** is `sample` when the effective parameter vectors - of its cells differ (after impositions), else `process` when the realizations - differ, else none. +- `sample` ∈ `over` iff the **effective parameter vectors** of the group's cells + differ, bit-compared from each cell's resolved parameters, which `push_cell` + already resolves to evaluate the quantity (`main.rs:2468-2481`); +- `replicate` ∈ `over` iff, for some sample in the group, the realizations of + that sample's replicates differ; +- `band` is `none` when the group is constant (this takes precedence), else the + measure name when `sample` ∈ `over`, else `process`. A quantity renders in **point mode** when every one of its groups is constant; its constant groups are collapsed to one realization _before_ `render_quantities` is called, whose point mode refuses more than one (`quantity_output.rs:351`, verified). Otherwise the quantity renders in **banded mode** for all its groups, because one file has one header; each row's `band` is -the group's attribution (the measure name for `sample`, `process`, or `none` for -a constant group in a banded file), and `summary.over` lists what actually -varied in that group. `StackedQuantities` therefore takes a mode per quantity -rather than one for the render. +the group's `band` as above, and `summary.over` lists what actually varied in +that group. `StackedQuantities` therefore takes a mode per quantity rather than +one for the render. Consequences, all intended: `--replicates N` under ODE renders pure-state quantities as points and observation quantities as bands (W8); a one-row sample @@ -1341,9 +1462,11 @@ reach the design path in Stage 5. ### 12.1 The `--sweep-file` reader -`--sweep-file` gets its own reader rather than `load_draws_tsv_keyed`, which -requires at least two columns (`main.rs:4402-4404`) and silently strips -`chain`/`draw` (`main.rs:4432`). The reader: +`--sweep-file` gets its own reader rather than `load_draws_tsv_keyed` +(`main.rs:4462`), which requires at least two columns (`main.rs:4472-4474`) and +silently strips `chain`/`draw` (`main.rs:4502`). The `--draws-file` reader keeps +`load_draws_tsv_keyed` but drops the two-column minimum, so a one-parameter +sample file loads. The `--sweep-file` reader: - accepts one or more parameter columns; tab-separated, with a message that detects a comma-separated file; @@ -1352,16 +1475,20 @@ requires at least two columns (`main.rs:4402-4404`) and silently strips - refuses `chain` or `draw` columns, with a hint to use `--draws-file`; - refuses a non-finite value, naming the line and column; - refuses duplicate rows, naming both line numbers (D-E); -- refuses a constant column, with a hint: "column `gamma` is the same on every - row, so it is not a coordinate; set it with --param or --params". +- refuses a constant column when the file has two or more rows, with a hint: + "column `gamma` is the same on every row, so it is not a coordinate; set it + with --param or --params". A one-row file is a single condition and every + column of it is a coordinate. ### 12.2 Parameters from outside the file `--fit` backfills a draws file's missing parameters from the fit's `[fixed]` -block (#273, `main.rs:1650-1669`). For `--draws-file` the backfill stays; +block (#273, `main.rs:1654-1673`). For `--draws-file` the backfill stays; backfilled columns are not file columns and are never varying. For `--sweep-file` backfilled constants apply to every point and never become design -names. `--fit requires --draws` (`main.rs:1168`) accepts `--draws-file`. +names. The check that `--fit` comes with a parameter source (`main.rs:1168`, +"--fit requires --draws") accepts any of `--draws`, `--draws-file`, `--sweep` +and `--sweep-file`. ### 12.3 Flags, conflicts, and what the dry run says @@ -1370,18 +1497,18 @@ names. `--fit requires --draws` (`main.rs:1168`) accepts `--draws-file`. compose. Several `--sweep` flags form a Cartesian product sorted by name; a repeated name is refused. - Conflict lists that name only `draws` gain `draws_file`, `sweep` and - `sweep_file`: `--stdout` (`args/mod.rs:431-434`), `--event-log` (`:563`), - `--reactive-log` (`:582`). `--reactive-log` mirrors only the last cell - (`main.rs:2116`), so a multi-cell design is refused rather than mirrored - partially. `--draws-out` (`requires = "draws"`, `:416`) stays tied to + `sweep_file`: `--stdout` (`args/mod.rs:435-438`), `--event-log` (`:567`), + `--reactive-log` (`:586`). `--reactive-log` mirrors only the last cell + (`main.rs:2137`), so a multi-cell design is refused rather than mirrored + partially. `--draws-out` (`requires = "draws"`, `:420`) stays tied to generated draws. - `--init-state fit` needs the posterior it pairs against (`main.rs:1149-1163`); with a design axis it is refused for the reason `conditioned_here` refuses a swept conditioned read. `--init-state FILE` refuses `--draws` today - (`main.rs:1314-1322`); it also refuses `--draws-file`, `--sweep-file` and + (`main.rs:1316-1324`); it also refuses `--draws-file`, `--sweep-file` and `--sweep`. -- `print_dry_run` (`main.rs:4462`) and the run-count banner - (`main.rs:1693-1701`) describe design points, samples, replicates, scenarios +- `print_dry_run` (`main.rs:4532`) and the run-count banner + (`main.rs:1683-1691`) describe design points, samples, replicates, scenarios and total cells; a grid above 10 000 cells prints a warning with the product spelled out. @@ -1389,15 +1516,20 @@ names. `--fit requires --draws` (`main.rs:1168`) accepts `--draws-file`. `--draws-out FILE` writes `FILE.json` beside the TSV: -| Field | Meaning | -| ---------------- | ----------------------------------------------------------------- | -| `schema` | `"camdl.draws/v1"` | -| `measure` | `posterior \| prior \| uniform_bounds` | -| `measure_source` | as in §9.3 (fit handle + run id + method, or prior source + hash) | -| `ir_hash` | structural hash of the model that generated the draws | -| `camdl_version` | `VERSION_SHORT` | -| `varying` | the columns written (§7.2) | -| `subsample` | `{"from": N, "stride": k}` when the source was strided | +| Field | Meaning | +| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `schema` | `"camdl.draws/v1"` | +| `measure` | `posterior \| prior \| uniform_bounds` | +| `measure_source` | as in §9.3 (fit handle + run id + method, or prior source + hash) | +| `ir_hash` | structural hash of the model that generated the draws | +| `camdl_version` | `VERSION_SHORT` | +| `varying` | the measure's varying columns (§7.2): for a posterior the estimated set, though the file also carries the fit's `[fixed]` columns; for `prior`/`uniform`, every column written | +| `subsample` | list of `{"from": N, "n": n}` steps, oldest first, when subsampled | + +A posterior export keeps today's columns — the estimated parameters and the +fit's `[fixed]` columns — so a reused posterior run without `--fit` still runs +its pinned parameters at the fitted values. `prior` and `uniform` exports carry +only the varying columns (gh#949). `--draws-file FILE` reads `FILE.json` when present: it carries the measure through (a posterior stays `posterior`), warns when `ir_hash` differs from the @@ -1406,23 +1538,24 @@ file and from where. `-n` subsamples a `--draws-file` only when given, strided across all rows through `subsample_draws`; the seed index is then the position in the subsample. There is no default cap — a cap would change which rows and seeds today's `--draws FILE` runs — so a file above 1000 rows prints a warning -suggesting `-n` instead (gh#630). +suggesting `-n` instead (gh#630). `-n` on a file whose sidecar already records a +subsample subsamples again from the file's rows and appends the step. ## 13. What changes for users, and what does not re-key -| # | Command | Today | After | -| -- | -------------------------------------------------------------------- | ------------------------------------------- | ----------------------------------------------------------------------------------------------- | -| 1 | `simulate --draws FILE …` | runs; pools a grid under `--quantities-out` | refused, naming `--sweep-file` and `--draws-file` (W9) | -| 2 | `simulate --draws-file FILE` | (does not exist) | today's `--draws FILE` seeds and trajectories; allowed under a scenario (W5) | -| 3 | `simulate --sweep`, `--sweep-file` | (do not exist) | one group per point; CRN across points; refused against a scenario or `--param` on those params | -| 4 | `simulate --draws-out PATH` | writes the file (all columns) | writes varying columns + `PATH.json`; prints the `--draws-file` hint | -| 5 | `batch run [design.NAME]` × colliding scenario | refused as "the sweep" | refused as "the design `NAME`" | -| 6 | Scenario, `--param` or design value setting a varying sampled column | runs silently | runs; note on stderr; recorded in `summary` | -| 7 | Banded quantity files (`simulate` and `fit predict`) | no statement of what the band is over | `band` column; `point_id` on design rows; exact `sweep:` values; manifest `mode`/`summary` | -| 8 | `fit predict --sweep` after the grid move | — | byte-identical to Stage 7's parent commit | -| 9 | Groups whose realizations are all bit-identical | zero-width band | point mode when every group of the quantity is constant (§10) | -| 10 | `--design-from` with a file | `--draws FILE` | `--draws-file` or `--sweep-file`; writes `truths.tsv`, `design_from.json` | -| 11 | `--draws-file` with `-n`; large files without `-n` | (`-n` ignored for `--draws FILE`) | strided subsample with `-n`; every row without it, plus a warning above 1000 rows | +| # | Command | Today | After | +| -- | -------------------------------------------------------------------- | ------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | +| 1 | `simulate --draws FILE …` | runs; pools a grid under `--quantities-out` | refused, naming `--sweep-file` and `--draws-file` (W9) | +| 2 | `simulate --draws-file FILE` | (does not exist) | today's `--draws FILE` seeds and trajectories; allowed under a scenario (W5) | +| 3 | `simulate --sweep`, `--sweep-file` | (do not exist) | one group per point; CRN across points; refused against a scenario or `--param` on those params | +| 4 | `simulate --draws-out PATH` | writes the file | same columns as today (posterior: estimated + `[fixed]`; prior/uniform: varying, since gh#949) + `PATH.json`; prints the `--draws-file` hint | +| 5 | `batch run [design.NAME]` × colliding scenario | refused as "the sweep" | refused as "the design `NAME`" | +| 6 | Scenario, `--param` or design value setting a varying sampled column | runs silently | runs; note on stderr; recorded in `summary` | +| 7 | Banded quantity files (`simulate` and `fit predict`) | no statement of what the band is over | `band` column; `point_id` on design rows; exact `sweep:` values; manifest `mode`/`summary` | +| 8 | `fit predict --sweep` after the grid move | — | byte-identical to Stage 7's parent commit | +| 9 | Groups whose realizations are all bit-identical | zero-width band | point mode when every group of the quantity is constant (§10) | +| 10 | `--design-from` with a file | `--draws FILE` | `--draws-file` or `--sweep-file`; writes `truths.tsv`, `design_from.json` | +| 11 | `--draws-file` with `-n`; large files without `-n` | (`-n` ignored for `--draws FILE`) | strided subsample with `-n`; every row without it, plus a warning above 1000 rows | **Run identity.** Every `sim` `run_id` folds the engine version, including the git hash (`version.rs:12-15`; `ModelDigest::from_model`, @@ -1431,12 +1564,12 @@ and "`run_id` unchanged" cannot be a test. The claim this proposal makes is **no re-key beyond the existing per-build engine-version fold**: for every job that exists today, the `params`, `scenario`, `seed` and `config` level hashes, the store path segments below the model level, and the trajectory bytes are -unchanged. The baseline is `main` **after gh#949**, not `ebe845a0`: gh#949 stops -generated rows from carrying parameters the measure does not sample, which by -itself changes resolved values (and so `params` level hashes) for -`--params X --draws uniform|prior`, and that change is gh#949's, not this -proposal's. The new flags are new keys: `--sweep` and `--sweep-file` runs have -no predecessors, and a `--sweep-file` ensemble folds its axis kind (§7.6). +unchanged. The baseline is `main` after gh#949 (`198dc096`, landed): gh#949 +changed resolved values (and so `params` level hashes) for +`--params X --draws uniform|prior` and for arms that had run at a leaked +scenario value, and those changes are gh#949's, not this proposal's. The new +flags are new keys: `--sweep` and `--sweep-file` runs have no predecessors, and +a `--sweep-file` ensemble folds its axis kind (§7.6). **IR and goldens are unaffected.** All changes are in `rust/crates/cli` and docs. No `ir/VERSION` bump, no `ocaml/` change, no golden regeneration. @@ -1451,7 +1584,7 @@ level, and output bytes — never whole `run_id`s (§13). **Stage 0 — one constructor for `CellSpec`.** `CasSink::predict_cells` calls `plan_grid`. Its iteration order today is point → scenario → seed -(`batch.rs:1282-1294`), `plan_grid`'s is scenario → point → rep, and the dry-run +(`batch.rs:1284-1296`), `plan_grid`'s is scenario → point → rep, and the dry-run and status displays print in prediction order; the predictions are re-sorted to point → scenario → seed before display, so the terminal output is unchanged. Test fixtures go through a `#[cfg(test)]` constructor. _Test:_ for a sweep × two @@ -1474,15 +1607,17 @@ old formula kept in the test as the oracle); an A/B of `simulate --draws uniform -n 5 --scenario a,b` and a `batch run` sweep comparing level hashes, path segments below the model, and every output file's bytes. -**Stage 2 — the guard reads the plan.** After gh#949: `varying` on `SampleAxis`; -the guard's new signature (§8); `DesignOrigin` nouns; the `scale` +**Stage 2 — the guard reads the plan.** On top of gh#949: `varying` on +`SampleAxis`; the guard's new signature (§8); `DesignOrigin` nouns; the `scale` reparameterization hint; `--param`/`--param-vec` in the footprint; impositions and design shadows returned and printed; the `--event-log` path routed through the guard; every `SimRun` site classified (§7.4). _Tests:_ the existing design-block test (`tests/scenario_coordinate_collision_gh572.rs:235-264`) updated to assert "the design `NAME`"; `--draws uniform --scenario pinned` exits -0 with the W3 note naming `beta`; a scenario over a column that rows carry but -the measure does not vary prints nothing and records nothing — tested with +0, the guard's returned `GuardFindings::impositions` holds `beta` (asserted in a +unit test), and stderr carries the W3 note naming `beta` (`summary.impositions` +arrives with the manifest in Stage 4); a scenario over a column that rows carry +but the measure does not vary prints nothing and records nothing — tested with posterior draws from a fit whose `[fixed]` block pins `N0` (the posterior `draws.tsv` carries the `N0` column, constant) under a scenario setting `N0`, and with a `--draws FILE` whose `N0` column is constant (the Stage 1 interim @@ -1494,45 +1629,58 @@ tested in Stage 5, when `simulate` first has a design axis). Mutation check: make the guard treat design names as sample columns and confirm the batch refusal test goes red. -**Stage 3 — provenance sidecar (D-D).** `--draws-out` writes varying columns and -`FILE.json`; the sidecar reader (used by `--draws FILE` until Stage 5 renames -it); `-n` subsamples a file. _Tests:_ -`--draws posterior -n 1000 --draws-out -p.tsv`, then reading `p.tsv` back yields -`measure: posterior` with the fit's handle; a sidecar whose `ir_hash` differs -prints the warning; `-n 7` on a 100-row file picks rows strided across the whole -file through the same `subsample_draws` as `--draws posterior`, never a prefix, -with seed index = position in the subsample; and a 250-row file read without -`-n` runs all 250 rows with trajectory bytes identical to the parent commit (no -default cap), printing no warning, while a 1500-row file prints the `-n` -warning. +**Stage 3 — provenance sidecar (D-D).** `--draws-out` keeps today's columns +(posterior: estimated + `[fixed]`) and adds `FILE.json`; the sidecar reader +(used by `--draws FILE` until Stage 5 renames it); `-n` subsamples a file. +_Tests:_ `--draws posterior -n 1000 --draws-out +p.tsv`, then reading `p.tsv` +back yields `measure: posterior` with the fit's handle, `varying` equal to the +estimated set, and the `[fixed]` columns present in the TSV; running that file +without `--fit` gives the same trajectory bytes as running it with `--fit`; a +sidecar whose `ir_hash` differs prints the warning; `-n 7` on a 100-row file +picks rows strided across the whole file through the same `subsample_draws` as +`--draws posterior`, never a prefix, with seed index = position in the +subsample; and a 250-row file read without `-n` runs all 250 rows with +trajectory bytes identical to the parent commit (no default cap), printing no +warning, while a 1500-row file prints the `-n` warning. **Stage 4 — output schema (D-C), its own commit, before Stage 7.** `band` column, `point_id` on design rows, exact `sweep:` formatting, manifest `mode`, `summary` always present, `partition_columns`, `measure_source`, `n_samples` -null when absent; `parameter_points.tsv` at round-trip precision. For -`fit predict`, Stage 4 does not emit `varying`, `design_shadows` or -`measure_note`: predict's sweep is not a design axis until Stage 7. This -**intentionally changes `fit predict` output**, and lands before the grid move -so Stage 7's byte-identity gate is against this schema. Contrasts note and -`report.json` record under `--sweep` (W7). _Tests:_ the predict header pin in -`tests/fit_predict_sweep.rs` updated to the new header; a sweep value -`0.1234567` renders exactly; every manifest entry validates against §9.3 (a test -walking the fields). +null when absent; `parameter_points.tsv` at round-trip precision; schema +`camdl.quantities/v2`. Per-group attribution (`band`, `summary.over`, §10) is +computed for **both** `simulate` and `fit predict`, while the point/banded +**mode predicate stays today's** (`main.rs:1952-1961` for `simulate`; predict +always bands). For `fit predict`, Stage 4 emits `varying` (known since Stage 2) +and withholds only `design_shadows` and `measure_note`, because predict's sweep +is not a design axis until Stage 7. This **intentionally changes `fit predict` +output**, and lands before the grid move so Stage 7's byte-identity gate is +against this schema. Contrasts note and `report.json` record under `--sweep` +(W7). _Tests:_ the predict header pin in `tests/fit_predict_sweep.rs` updated to +the new header; a sweep value `0.1234567` renders exactly; every manifest entry +validates against §9.3 (a test walking the fields), with `design_shadows` and +`measure_note` exempted for predict entries until Stage 7. **Stage 5 — the flag split, `simulate --sweep`, and design grouping, together.** `--sweep-file` (reader §12.1), `--draws-file`, `simulate --sweep`; the bare `--draws ` refusal and the typo message; D-B seeds for design jobs; -`by_partition` and per-quantity mode (§10) in the same stage, so no build can -pool design points (the interim refusal of Stage 1 is lifted here, for -`--draws-file` only). Combined writer columns (§9.2) and the ensemble axis fold; -flag conflicts, dry-run and banner (§12.3); `--init-state` refusals; `--fit` -backfill rules. Docs in the same stage: every `--draws FILE` in -`docs/camdl-run-spec.md` (lines 1036, 1053, 1058, 1131, 1158, 1165, 1515, 2000, -2065, 2146, 5249, 5293, 6160, 6187, and the recipe at 1976-1978), +`by_partition` and the §10 bit-identical mode rule in the same stage — for +**both** verbs, so `fit predict` also renders a quantity whose every group is +constant in point mode; that predict output change is intended and pinned by a +test (a predict run whose quantity is a function of a `[fixed]` parameter only +renders `value`, not a zero-width band) — so no build can pool design points +(the interim refusal of Stage 1 is lifted here, for `--draws-file` only). +Combined writer columns (§9.2) and the ensemble axis fold; flag conflicts, +dry-run and banner (§12.3); `--init-state` refusals; `--fit` backfill rules and +the `--fit` source check (§12.2); `--draws-file` routed into `--design-from` +with today's behaviour (one dataset per row, dataset-index seeds), so +`--design-from` never loses file input between this stage and Stage 6. Docs in +the same stage: every `--draws FILE` in `docs/camdl-run-spec.md` (lines 1041, +1058, 1063, 1137, 1164, 1171, 1521, 2006, 2074, 2155, 5258, 5302, 6169, the +`--design-from` help at 6190-6195, and the recipe at 1982-1984), `docs/mre.md:50`, `args/mod.rs:305` (after-help), the `--draws` and `--fit` help -(`args/mod.rs:386-396`), the `--design-from` help (`:478-480`), and the run-spec -sentence on `--draws uniform` (1145-1148). Tests that pass `--draws FILE` move +(`args/mod.rs:386-399`), the `--design-from` help (`:482-484`), and the run-spec +sentence on `--draws uniform` (1150-1154). Tests that pass `--draws FILE` move to `--draws-file`: `tests/simulate_identity_parity.rs:270`, `:317`, `tests/gh641_init_state_forecast.rs:809`, `tests/scenario_coordinate_collision_gh572.rs:282-334` (the last now asserts the @@ -1560,8 +1708,9 @@ the `baseline` rows `uniform_bounds`. Mutation check: key `by_partition` on the scenario alone and confirm the three-row assertion fails on the row count, not the header. -**Stage 6 — `--design-from` on the new axes.** Accept every source; priors from -the design `fit.toml`; write `truths.tsv` and `design_from.json`. Dataset files +**Stage 6 — `--design-from` on the new axes.** Accept `--sweep`, `--sweep-file` +and the remaining sources; priors from the design `fit.toml`; write `truths.tsv` +and `design_from.json`; seeds stay by dataset index (W11). Dataset files byte-identical. _Test:_ `--sweep-file truths.tsv` writes one `ds_NN/` per row, `truths.tsv` with `point_id`, and `"axis": "design"`; `--draws prior -n 3` gives `"axis": "sample", "measure": "prior"` with the design fit.toml's hash. @@ -1569,43 +1718,49 @@ byte-identical. _Test:_ `--sweep-file truths.tsv` writes one `ds_NN/` per row, **Stage 7 — `fit predict` on the grid.** One plan; `DesignAxis::sweep` carries predict's name checks; the pre-flight guard call stays before the free-forward closure; rows rendered design-major explicitly; to bound memory, one `run_job` -per design point (a design slice) rather than one for the whole grid, since +call per design point (a design slice) rather than one for the whole grid, since `run_job` holds every cell's result until the merge phase (`engine.rs:232-247`). -A slice is **one global plan filtered by design index** — `plan_grid` over the -whole plan, then the cells whose `DesignCoord::Point { idx }` equals the slice — -never a per-point sub-plan, so every cell keeps its global design index and -partition keys from different slices cannot collide; the per-cell conditioned -decision is a function of `PartitionKey` (conditioned iff the scenario is the -conditioned arm and the design is `Unit`), replacing the per-sink `conditioned` -/ `chain_of_point` fields (`predict.rs:2196-2218`); `FreeForwardCell` and -`assemble_predictive` keyed by `PartitionKey`. _Tests:_ byte-identity A/B -against **Stage 7's parent commit** (post-Stage 6) of every predict artifact -(predictive TSVs, quantities TSVs, manifests, contrasts, `report.json`) for +A slice is **one global plan filtered by design index**: `plan_grid` runs once +over the whole plan, and each slice is the `Vec` of cells whose +`DesignCoord::Point { idx }` equals the slice. `run_job` gains a sibling entry, +`run_cells(job: &SimulateJob, cells: Vec, grid: &Grid, sink)`, that +runs a given cell list instead of planning one; `run_job` becomes `plan_grid` +then `run_cells`. Never a per-point sub-plan, so every cell keeps its global +design index and partition keys from different slices cannot collide; the +per-cell conditioned decision is a function of `PartitionKey` (conditioned iff +the scenario is the conditioned arm and the design is `Unit` — so conditioned +reads occur only in a job without `--sweep`, exactly as today), replacing the +per-sink `conditioned` / `chain_of_point` fields (`predict.rs:2196-2218`); +`FreeForwardCell` and `assemble_predictive` keyed by `PartitionKey`. _Tests:_ +byte-identity A/B against **Stage 7's parent commit** (post-Stage 6, already on +the §10 mode rule since Stage 5) of every predict artifact (predictive TSVs, +quantities TSVs, manifests, contrasts, `report.json`) for `--sweep k=… --scenario a,b` with and without a conditioned fit. These files carry no version string or timestamp, so they are compared unmasked: -`report.json` holds only `schema` and `failures` (`fit/failures.rs:97-100`, -verified), the quantities manifest holds `schema`, `calendar` and `quantities` -plus, under `--quantities`, a `vocabulary` object of file path and sha256 -(`quantity_output.rs:511-515`, `quantities_file.rs:66-71`, verified), and -neither `predict.rs`, `quantity_output.rs` nor `contrasts.rs` references -`version::` or a clock (verified by search); the A/B runs both builds from the -same working directory so the vocabulary path is equal. A unit test runs two -design points of one global plan as two slices through one sink and one -`FreeForwardCell` map and asserts two groups with distinct partition keys (a -per-slice plan would give both `design: Some(0)` and merge them); -`--sweep beta=… --scenario distancing` exits non-zero with **no** artifact -written; a unit test that the conditioned decision is true only for the un-swept -`fitted` group. Mutation check: key the sink on scenario alone and confirm the -four-group assertion fails. **After the A/B passes**, a separate commit adds -predict's `varying`, `design_shadows` and `measure_note` to the manifest and the -design-shadow stderr note (W6); it is an intended output change, tested by -asserting `"design_shadows": ["gamma"]` for `--sweep gamma=…`. +`report.json` holds `schema` and `failures` (`fit/failures.rs:97-100`, verified) +plus, since Stage 4, the deterministic `contrasts` record of W7, the quantities +manifest holds `schema`, `calendar` and `quantities` plus, under `--quantities`, +a `vocabulary` object of file path and sha256 (`quantity_output.rs:511-515`, +`quantities_file.rs:66-71`, verified), and neither `predict.rs`, +`quantity_output.rs` nor `contrasts.rs` references `version::` or a clock +(verified by search); the A/B runs both builds from the same working directory +so the vocabulary path is equal. A unit test runs two design points of one +global plan as two slices through one sink and one `FreeForwardCell` map and +asserts two groups with distinct partition keys (a per-slice plan would give +both `design: Some(0)` and merge them); `--sweep beta=… --scenario distancing` +exits non-zero with **no** artifact written; a unit test that the conditioned +decision is true only for the un-swept `fitted` group. Mutation check: key the +sink on scenario alone and confirm the four-group assertion fails. **After the +A/B passes**, a separate commit adds predict's `design_shadows` and +`measure_note` to the manifest and the design-shadow stderr note (W6); it is an +intended output change, tested by asserting `"design_shadows": ["gamma"]` for +`--sweep gamma=…`. **Stage 8 — error text.** `ValueSource::{Sample, DesignPoint}`; `UnknownParameter` names the column and the file; `NonFiniteValue` names the flag the command has. _Tests:_ `params_resolver.rs` unit tests at `:2206`, `:2238`, `:2310` updated to the new variants; a CLI test that a misspelt -`--draws-file` column prints `column`betta`of`post.tsv`is not a parameter`. +`--draws-file` column prints "column `betta` of `post.tsv` is not a parameter". ## 15. Follow-ups, to be filed @@ -1634,7 +1789,7 @@ the alpha posture: break with the replacement spelled out, no shim. `[design.NAME]` in `batch run` keeps its name, because there "design" names a generator (`sobol | lhs | random`); `--design-from` is unchanged, because there "design" names the fit's observation schedule. An in-file declaration was not -chosen: the reader takes line 1 as the header (`main.rs:4392-4399`), so a +chosen: the reader takes line 1 as the header (`main.rs:4462-4469`), so a directive line would break older camdl and every other reader of these files. **D-B — Design coordinates never enter the process seed.** `seed_index` is the @@ -1645,7 +1800,10 @@ are then low-variance, and inserting a point into a sweep leaves the others' trajectories unchanged. `--sweep` and `--sweep-file` are new, so nothing re-keys; `--draws-file` keeps today's `--draws FILE` seeds exactly. The cost is stated in §7.3: one file read as a design and as a sample does not share store -leaves. +leaves. `--design-from` is the one stated exception: its datasets keep +dataset-index seeds, because they are independent replicates of a +self-consistency experiment rather than paired conditions, and correlating their +process noise would undercut the independence the downstream analysis assumes. **D-C — The output schema is fixed before predict's bytes are frozen.** A `point_id` on every design-bearing row (the file's, else the 0-based index), so @@ -1653,22 +1811,26 @@ rows join without float matching; design coordinates written round-trip exact; a `band` column naming, per row, what that group's band is over; and a manifest that describes itself (`mode`, `summary` always, `partition_columns`, `measure_source` with a fit handle and hash rather than a path, `n_samples` null -when absent, `n_samples_used` where rows can drop). This changes `fit predict` -output, so it lands as its own commit (Stage 4) before the grid move, whose -byte-identity gate is then against its parent commit, already on the new schema. -Point versus band is decided from the realizations of each group (§10), not from -the backend or the quantity's source, so `summary.over` reports what actually -varied. - -**D-D — Draws files carry their provenance.** `--draws-out` writes a sidecar -recording measure, source, IR hash, version, varying columns and subsampling; -`--draws-file` reads it, so an exported posterior stays a posterior, a stale -export against a changed model is flagged, and parameters from outside the file -are named. `-n` subsamples a `--draws-file` when given (seed index = position in -the subsample), closing the gh#630 path by which a raw posterior replays every -row unasked. There is no default cap: a cap would change the rows and seeds of -today's `--draws FILE` runs and break D-B's promise that `--draws-file` keeps -them exactly; a file above 1000 rows warns instead. +when absent, `n_samples_used` where rows can drop). The manifest schema becomes +`camdl.quantities/v2`. This changes `fit predict` output, so it lands as its own +commit (Stage 4) before the grid move. Stage 4 adds attribution under the old +mode predicate; Stage 5 moves both verbs to the bit-identical rule; Stage 7's +byte-identity gate is then against a parent already on the final schema and the +final rule. Point versus band is decided from the realizations of each group +(§10), not from the backend or the quantity's source, so `summary.over` reports +what actually varied. + +**D-D — Draws files carry their provenance.** `--draws-out` keeps today's +columns (a posterior keeps its `[fixed]` columns, so a reused posterior needs no +`--fit`) and writes a sidecar recording measure, source, IR hash, version, the +measure's varying columns and the subsampling chain; `--draws-file` reads it, so +an exported posterior stays a posterior, a stale export against a changed model +is flagged, and parameters from outside the file are named. `-n` subsamples a +`--draws-file` when given (seed index = position in the subsample), closing the +gh#630 path by which a raw posterior replays every row unasked. There is no +default cap: a cap would change the rows and seeds of today's `--draws FILE` +runs and break D-B's promise that `--draws-file` keeps them exactly; a file +above 1000 rows warns instead. **D-E — Duplicate design rows are refused only in `--sweep-file`.** In a hand-written file a duplicate is almost always an error, so it is refused with @@ -1692,10 +1854,12 @@ added in the commit after the A/B passes. It puts predict's partition coordinate in the cell and lets one guard and one grouping derivation serve both verbs, removing rejection reason 2. -**Impositions are allowed and recorded, not refused.** A scenario, `--param` or -design value that sets a varying sampled column is an intervention on that -parameter; the note says so in plain words (W3) and suggests `scale` when the -intent is to reduce transmission relative to each draw. +**Impositions and design shadows are allowed and recorded, not refused.** A +scenario `set` or `--param` on a varying sampled column (an imposition), or a +design coordinate on one (a design shadow), is an intervention on that +parameter. The guard returns the two as separate lists in `GuardFindings`; the +note says so in plain words (W3) and suggests `scale` when the intent is to +reduce transmission relative to each draw. **Every design origin uses the `sweep:` column prefix,** leaving `fit predict`'s header prefix and the manifest `sweep` object unchanged. @@ -1713,7 +1877,7 @@ composition — sweep `beta ∈ {0.15, 0.3, 0.6}` under `distancing` running `{0.09, 0.18, 0.36}` — the representation is fixed here: - **Identity hashes only effective values.** It does already - (`batch.rs:1141-1153`, `resolve.rs:231`, verified). Cells whose effective + (`batch.rs:1143-1155`, `resolve.rs:231`, verified). Cells whose effective values coincide under different requested coordinates share their level hashes and are one computation in the store. - **The grouping key is the requested coordinate** — the design point's index