Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions rust/crates/cli/src/profile.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<f64>)> = focal_grids.iter()
.map(|fg| (fg.name.clone(), fg.values.clone()))
.collect();
let resolve_pt = |gi: usize, si: usize, seed: u64|
-> Result<crate::profile_cas::ResolvedProfilePoint, String>
{
Expand All @@ -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(),
Expand Down
22 changes: 22 additions & 0 deletions rust/crates/cli/src/profile_cas.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<f64>)],
/// The resolved profile seed (the `seed` level hashes this).
pub seed: u64,
pub start_index: u32,
Expand All @@ -70,6 +75,22 @@ pub fn resolve_profile_point(ctx: &ProfilePointCtx) -> Result<ResolvedProfilePoi
ensure_finite(ctx.base_config)?;
ensure_finite(ctx.method_config)?;

// Canonicalize the sweep grid: axes sorted by name, values ascending, so
// the identity depends only on the *set* of cells, not `--sweep` order.
let mut axes: Vec<(String, Vec<f64>)> = ctx.grid.to_vec();
axes.sort_by(|a, b| a.0.cmp(&b.0));
let mut grid: Vec<(ParamId, Vec<FiniteF64>)> = 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,
Expand All @@ -78,6 +99,7 @@ pub fn resolve_profile_point(ctx: &ProfilePointCtx) -> Result<ResolvedProfilePoi
),
data: data_digests(ctx.data)?,
base_config: digest_value(ctx.base_config),
grid,
engine: EngineVersion(ctx.engine_version.to_string()),
deps: Deps(ctx.deps.clone()),
};
Expand Down
150 changes: 150 additions & 0 deletions rust/crates/cli/tests/profile_grid_identity.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
//! A `camdl profile` run's storage folder (its content-addressed *base* dir)
//! includes the sweep grid, so a **distinct grid is a distinct run**. Before
//! this, the base was keyed on the model + swept-param *names* but not the
//! sweep *values*, so re-running the same params over a different range merged
//! the two grids' cells into one base dir — a jagged union when the cell
//! coordinates didn't line up (garki report).
//!
//! This pins the fix from outside: running two grids (same model + same swept
//! params, different ranges) into the same output root produces two distinct
//! base dirs, while re-running the *same* grid reuses its base dir (stable —
//! still a cache hit within a grid).

use std::collections::BTreeSet;
use std::path::{Path, PathBuf};
use std::process::Command;

fn camdl_bin() -> 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 `<root>/profiles/` (the `<stem>-<basehash>` folders).
fn base_dirs(root: &Path) -> BTreeSet<String> {
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");
}
21 changes: 17 additions & 4 deletions rust/crates/runid/src/inputs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<DataDigest>,
/// 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<FiniteF64>)>,
pub engine: EngineVersion,
pub deps: Deps,
}
Expand Down
Loading