diff --git a/rust/crates/cli/src/profile.rs b/rust/crates/cli/src/profile.rs index 24463fe6..547661fd 100644 --- a/rust/crates/cli/src/profile.rs +++ b/rust/crates/cli/src/profile.rs @@ -1057,6 +1057,13 @@ pub fn cmd_profile(a: &crate::args::ProfileArgs) { .flat_map(move |gi| (0..n_starts).map(move |si| (seed_idx, gi, si)))) .collect(); + // The full sweep grid (each focal axis name and its values), shared by + // every point. Folded into each point's base identity so a distinct grid + // (wider range, more points, shifted bounds) is a distinct run rather than + // a silent merge onto the previous grid's cells. + let grid_spec: Vec<(String, Vec)> = focal_grids.iter() + .map(|fg| (fg.name.clone(), fg.values.clone())) + .collect(); let resolve_pt = |gi: usize, si: usize, seed: u64| -> Result { @@ -1074,6 +1081,7 @@ pub fn cmd_profile(a: &crate::args::ProfileArgs) { base_config: &base_config_blob, method_config: &method_config_blob, focal: &focal, + grid: &grid_spec, seed, start_index: si as u32, deps: profile_deps.clone(), diff --git a/rust/crates/cli/src/profile_cas.rs b/rust/crates/cli/src/profile_cas.rs index 1afd7438..ea73dd2b 100644 --- a/rust/crates/cli/src/profile_cas.rs +++ b/rust/crates/cli/src/profile_cas.rs @@ -53,6 +53,11 @@ pub struct ProfilePointCtx<'a> { pub method_config: &'a serde_json::Value, /// The pinned focal `(param, value)` for this grid point. pub focal: &'a [(String, f64)], + /// The full resolved sweep grid — each focal axis name and its values, + /// shared across every point of this run. Folded into the base identity so + /// a different grid (wider range, more points, shifted bounds) is a + /// distinct run rather than a merge onto the previous grid's cells. + pub grid: &'a [(String, Vec)], /// The resolved profile seed (the `seed` level hashes this). pub seed: u64, pub start_index: u32, @@ -70,6 +75,22 @@ pub fn resolve_profile_point(ctx: &ProfilePointCtx) -> Result)> = ctx.grid.to_vec(); + axes.sort_by(|a, b| a.0.cmp(&b.0)); + let mut grid: Vec<(ParamId, Vec)> = Vec::with_capacity(axes.len()); + for (name, mut values) in axes { + values.sort_by(|a, b| a.partial_cmp(b) + .expect("grid values are finite (checked at parse)")); + let mut fvs = Vec::with_capacity(values.len()); + for v in values { + fvs.push(FiniteF64::new(v) + .map_err(|_| format!("non-finite grid value for axis '{}'", name))?); + } + grid.push((ParamId(name), fvs)); + } + let base = ProfileBase { model: ModelDigest::from_model( ctx.model, @@ -78,6 +99,7 @@ pub fn resolve_profile_point(ctx: &ProfilePointCtx) -> Result PathBuf { + let manifest = std::env::var("CARGO_MANIFEST_DIR") + .expect("CARGO_MANIFEST_DIR set under cargo test"); + let p = Path::new(&manifest).join("../../target/release/camdl"); + assert!( + p.exists(), + "release camdl binary missing: {} - run `make build-rust` or `make test` (gh#105)", + p.display() + ); + p +} + +fn camdlc_bin() -> PathBuf { + let manifest = std::env::var("CARGO_MANIFEST_DIR") + .expect("CARGO_MANIFEST_DIR set under cargo test"); + let p = Path::new(&manifest).join("../../../ocaml/_build/default/bin/camdlc.exe"); + assert!(p.exists(), "camdlc.exe missing: {} - run `make build-ocaml`", p.display()); + p +} + +struct Tmp(PathBuf); +impl Tmp { fn path(&self) -> &Path { &self.0 } } +impl Drop for Tmp { fn drop(&mut self) { let _ = std::fs::remove_dir_all(&self.0); } } +fn tempdir(tag: &str) -> Tmp { + let ns = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos(); + let base = std::env::temp_dir().join(format!( + "camdl_profile_grid_{}_{}_{}", tag, std::process::id(), ns)); + std::fs::create_dir_all(&base).unwrap(); + Tmp(base) +} + +fn write_fixture(dir: &Path) -> (PathBuf, PathBuf) { + let camdlc = camdlc_bin(); + let src = r#" +time_unit = 'days +compartments { S, I, R } +parameters { + beta : rate in [0.001, 5.0] + gamma : rate in [0.01, 1.0] + N0 : count in [100, 10000] +} +transitions { + infection : S --> I @ beta * S * I / N0 + recovery : I --> R @ gamma * I +} +observations { + cases { + columns { time : time, cases : count } + projected = prevalence(I) + emit_schedule = every 1 'days + cases ~ poisson(rate = projected) + } +} +scenarios { + baseline { set = { beta = 0.3 gamma = 0.1 N0 = 1000 } } +} +init { S = 999 I = 1 } +simulate { from = 0 'days to = 6 'days } +"#; + let model_path = dir.join("sir.camdl"); + std::fs::write(&model_path, src).unwrap(); + let ir_path = dir.join("sir.ir.json"); + let out = Command::new(&camdlc).arg(&model_path).output().unwrap(); + assert!(out.status.success(), + "camdlc failed: {}", String::from_utf8_lossy(&out.stderr)); + std::fs::write(&ir_path, &out.stdout).unwrap(); + + let data_path = dir.join("cases.tsv"); + std::fs::write(&data_path, + "time\tcases\n1\t2\n2\t4\n3\t8\n4\t6\n5\t4\n6\t2\n").unwrap(); + (ir_path, data_path) +} + +/// Run a profile with the given sweep specs into `root` (`CAMDL_OUTPUT_DIR`). +fn run_profile(bin: &Path, ir: &Path, data: &Path, root: &Path, beta: &str, gamma: &str) { + let out = Command::new(bin) + .env("CAMDL_SKIP_VERSION_CHECK", "1") + .env("CAMDL_OUTPUT_DIR", root) + .args([ + "profile", &ir.to_string_lossy(), + "--scenario", "baseline", + "--data", &data.to_string_lossy(), + "--obs", "cases", + "--sweep", beta, + "--sweep", gamma, + "--algorithm", "if2", + "--particles", "30", + "--iterations", "5", + "--starts", "1", + "--rw-sd", "auto", + "--seed", "1", + ]) + .output().expect("spawn camdl profile"); + assert!(out.status.success(), + "camdl profile must exit 0.\nstderr:\n{}", String::from_utf8_lossy(&out.stderr)); +} + +/// The base dirs under `/profiles/` (the `-` folders). +fn base_dirs(root: &Path) -> BTreeSet { + let profiles = root.join("profiles"); + std::fs::read_dir(&profiles).map(|rd| rd.flatten() + .filter(|e| e.path().is_dir()) + .map(|e| e.file_name().to_string_lossy().into_owned()) + .collect()).unwrap_or_default() +} + +#[test] +fn distinct_grids_get_distinct_base_dirs() { + let bin = camdl_bin(); + let tmp = tempdir("id"); + let (ir, data) = write_fixture(tmp.path()); + let root = tmp.path().join("out"); + + // Grid A. + run_profile(&bin, &ir, &data, &root, "beta=lin(0.1,0.5,3)", "gamma=lin(0.03,0.25,3)"); + let after_a = base_dirs(&root); + assert_eq!(after_a.len(), 1, "one grid → one base dir, got {:?}", after_a); + + // Grid A again — same grid must reuse the same base dir (still cache-stable). + run_profile(&bin, &ir, &data, &root, "beta=lin(0.1,0.5,3)", "gamma=lin(0.03,0.25,3)"); + assert_eq!(base_dirs(&root), after_a, + "re-running the SAME grid must reuse its base dir, not fork a new one"); + + // Grid B — same model + same swept params, DIFFERENT ranges. Must be its + // own base dir, not a merge onto grid A (the garki bug). + run_profile(&bin, &ir, &data, &root, "beta=lin(0.2,0.6,3)", "gamma=lin(0.05,0.3,3)"); + let after_b = base_dirs(&root); + assert_eq!(after_b.len(), 2, + "a DIFFERENT grid must create a distinct base dir, not merge onto the \ + previous one — got {:?}", after_b); + assert!(after_b.is_superset(&after_a), + "grid A's base dir must still be present alongside grid B's"); +} diff --git a/rust/crates/runid/src/inputs.rs b/rust/crates/runid/src/inputs.rs index f5d6254f..76a9b742 100644 --- a/rust/crates/runid/src/inputs.rs +++ b/rust/crates/runid/src/inputs.rs @@ -406,15 +406,28 @@ pub struct SurveyInput { // (seed, start) pair pins the sub-fit's init deterministically. /// The shared `profile`-level digest: the inference problem being profiled, -/// with the focal grid and method config excluded. `deps` carries the base -/// fit's `starts_from` lineage (same deps-DAG mechanism as a fit stage). -#[derive(Debug, Clone, PartialEq, Eq, RunInput)] +/// including the sweep grid so a distinct grid is a distinct run. `deps` +/// carries the base fit's `starts_from` lineage (same deps-DAG mechanism as a +/// fit stage). +/// +/// The grid is part of the identity (not `Eq` because it carries floats — same +/// as [`ProfilePointConfig`]). Two runs with the same problem but a *different* +/// grid — a wider range, more points, shifted bounds — get different base dirs +/// rather than silently merging their cells onto one another (which produced a +/// jagged union when the cell coordinates didn't line up). Re-running the +/// *same* grid is stable (a cache hit); the method config still lives in the +/// `stage` level, not here. +#[derive(Debug, Clone, PartialEq, RunInput)] pub struct ProfileBase { pub model: ModelDigest, pub data: Vec, /// Canonical digest of the base config (base params + fixed + obs + priors - /// + fit.toml) — grid and method config excluded. + /// + fit.toml) — method config excluded (it lives in `stage`). pub base_config: ContentHash, + /// The resolved sweep grid: each focal axis and its values, canonicalized + /// (axes sorted by name, values ascending) so the identity is independent + /// of `--sweep` argument order. + pub grid: Vec<(ParamId, Vec)>, pub engine: EngineVersion, pub deps: Deps, }