From 7e748dde5a7b733b7265daadabe2416fec0452b6 Mon Sep 17 00:00:00 2001 From: jgabry Date: Mon, 24 Aug 2026 17:36:17 -0600 Subject: [PATCH 1/3] zero pad chain IDs in file names --- NEWS.md | 3 ++- R/fit.R | 3 ++- R/utils.R | 2 +- man/fit-method-save_output_files.Rd | 3 ++- tests/testthat/test-fit-shared.R | 4 ++-- tests/testthat/test-model-laplace.R | 6 +++--- tests/testthat/test-model-output_dir.R | 16 ++++++++-------- tests/testthat/test-model-pathfinder.R | 4 ++-- tests/testthat/test-utils.R | 12 ++++++++++++ 9 files changed, 34 insertions(+), 19 deletions(-) diff --git a/NEWS.md b/NEWS.md index 55224222f..b82a3e592 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,5 +1,6 @@ # cmdstanr (development version) +* Chain IDs in generated filenames are now zero-padded to at least two digits, for example `01` instead of `1`. * When using CmdStan through WSL, paths for output, diagnostic, profile, config, and metric files now remain accessible to Windows R when an explicit output directory is supplied. (#1110; related: #1113) @@ -80,7 +81,7 @@ CmdStan 2.39 or newer, and `read_cmdstan_csv()` returns these times from standalone generated quantities CSV files. (#1168) * `laplace()` no longer overwrites the internally generated optimizer CSV when `mode = NULL` and `output_basename` is supplied. The internally generated -optimizer CSV now uses the filename `-mode-1.csv`. +optimizer CSV now uses the filename `-mode-01.csv`. * CmdStanModel objects created using `compile_model_methods = TRUE` that are then saved and reloaded no longer error in model fitting methods. Model methods diff --git a/R/fit.R b/R/fit.R index c6f9ee35f..397c96af6 100644 --- a/R/fit.R +++ b/R/fit.R @@ -1000,7 +1000,8 @@ CmdStanFit$set("public", name = "cmdstan_diagnose", value = cmdstan_diagnose) #' * `basename` is the user's provided `basename` argument or, if `NULL`, the #' model name; #' * `timestamp` is of the form `format(Sys.time(), "%Y%m%d%H%M")`; -#' * `id` is the MCMC chain id (or `1` for non MCMC); +#' * `id` is the MCMC chain id, zero-padded to at least two digits (or `01` for +#' non-MCMC); #' * `random` contains six random hexadecimal characters. #' #' `$save_latent_dynamics_files()` uses the pattern diff --git a/R/utils.R b/R/utils.R index 56972d027..002d92a65 100644 --- a/R/utils.R +++ b/R/utils.R @@ -266,7 +266,7 @@ generate_file_names <- new_names <- paste0(new_names, "-", stamp) } if (!is.null(ids)) { - new_names <- paste0(new_names, "-", ids) + new_names <- paste0(new_names, "-", sprintf("%02d", ids)) } if (random) { rand_num_pid <- as.integer(stats::runif(1, min = 0, max = 1E7)) + Sys.getpid() diff --git a/man/fit-method-save_output_files.Rd b/man/fit-method-save_output_files.Rd index 336bb85d2..25fed7136 100644 --- a/man/fit-method-save_output_files.Rd +++ b/man/fit-method-save_output_files.Rd @@ -104,7 +104,8 @@ the form \code{basename-timestamp-id-random.csv}, where \item \code{basename} is the user's provided \code{basename} argument or, if \code{NULL}, the model name; \item \code{timestamp} is of the form \code{format(Sys.time(), "\%Y\%m\%d\%H\%M")}; -\item \code{id} is the MCMC chain id (or \code{1} for non MCMC); +\item \code{id} is the MCMC chain id, zero-padded to at least two digits (or \code{01} for +non-MCMC); \item \code{random} contains six random hexadecimal characters. } diff --git a/tests/testthat/test-fit-shared.R b/tests/testthat/test-fit-shared.R index fce692ca9..c295267be 100644 --- a/tests/testthat/test-fit-shared.R +++ b/tests/testthat/test-fit-shared.R @@ -37,7 +37,7 @@ test_that("saving csv output files works", { should_match <- paste0("testing-output-", base::format(Sys.time(), "%Y%m%d%H%M"), "-", - seq_len(fit$num_procs())) + sprintf("%02d", seq_len(fit$num_procs()))) for (j in seq_along(paths)) { expect_match(paths[j], should_match[j]) } @@ -72,7 +72,7 @@ test_that("saving diagnostic csv output works", { should_match <- paste0("testing-output-diagnostic-", base::format(Sys.time(), "%Y%m%d%H%M"), "-", - seq_len(fit$num_procs())) + sprintf("%02d", seq_len(fit$num_procs()))) for (j in seq_along(paths)) { expect_match(paths[j], should_match[j]) diff --git a/tests/testthat/test-model-laplace.R b/tests/testthat/test-model-laplace.R index 3d4def90a..213f36b73 100644 --- a/tests/testthat/test-model-laplace.R +++ b/tests/testthat/test-model-laplace.R @@ -76,11 +76,11 @@ test_that("laplace() avoids output_basename conflict with internal optimize()", ) ) - expect_equal(basename(fit$output_files()), "custom-laplace-1.csv") - expect_equal(basename(fit$mode()$output_files()), "custom-laplace-mode-1.csv") + expect_equal(basename(fit$output_files()), "custom-laplace-01.csv") + expect_equal(basename(fit$mode()$output_files()), "custom-laplace-mode-01.csv") expect_setequal( list.files(output_dir, pattern = "\\.csv$"), - c("custom-laplace-1.csv", "custom-laplace-mode-1.csv") + c("custom-laplace-01.csv", "custom-laplace-mode-01.csv") ) }) diff --git a/tests/testthat/test-model-output_dir.R b/tests/testthat/test-model-output_dir.R index e8fe92b60..bac9eaac7 100644 --- a/tests/testthat/test-model-output_dir.R +++ b/tests/testthat/test-model-output_dir.R @@ -47,7 +47,7 @@ test_that("WSL output paths stay host-native until command composition", { output_basename = "model" ) }) - output_files <- file.path(host_dirs, "model-1.csv") + output_files <- file.path(host_dirs, "model-01.csv") expect_equal( vapply(args, function(x) x$output_dir, character(1)), host_dirs @@ -62,15 +62,15 @@ test_that("WSL output paths stay host-native until command composition", { ) sub("file=", "", command_args[grepl("^file=", command_args)], fixed = TRUE) }, character(1)) - expect_equal(cmdstan_output_files, file.path(wsl_dirs, "model-1.csv")) + expect_equal(cmdstan_output_files, file.path(wsl_dirs, "model-01.csv")) command_args <- args[[1]]$compose_all_args( output_file = output_files[1], - profile_file = file.path(host_dirs[1], "model-profile-1.csv"), - latent_dynamics_file = file.path(host_dirs[1], "model-diagnostic-1.csv") + profile_file = file.path(host_dirs[1], "model-profile-01.csv"), + latent_dynamics_file = file.path(host_dirs[1], "model-diagnostic-01.csv") ) - expect_in("diagnostic_file=/mnt/c/output/model-diagnostic-1.csv", command_args) - expect_in("profile_file=/mnt/c/output/model-profile-1.csv", command_args) + expect_in("diagnostic_file=/mnt/c/output/model-diagnostic-01.csv", command_args) + expect_in("profile_file=/mnt/c/output/model-profile-01.csv", command_args) # Omitting output_dir must still use the faster WSL-native temp directory. default_args <- CmdStanArgs$new( @@ -82,7 +82,7 @@ test_that("WSL output paths stay host-native until command composition", { ) expect_equal(default_args$output_dir, "//wsl$/Ubuntu/tmp/cmdstanr") expect_in( - "file=/tmp/cmdstanr/model-1.csv", + "file=/tmp/cmdstanr/model-01.csv", default_args$compose_all_args( output_file = default_args$new_files("output") ) @@ -145,7 +145,7 @@ test_that("all fitting methods work with output_dir", { fit <- testing_fit("bernoulli", method = method, seed = 123, output_basename = "custom") n_files <- length(fit$output_files()) - files <- paste0("custom-", 1:n_files, ".csv") + files <- sprintf("custom-%02d.csv", seq_len(n_files)) expect_equal(basename(fit$output_files()), files) } diff --git a/tests/testthat/test-model-pathfinder.R b/tests/testthat/test-model-pathfinder.R index 6489f40b5..fc1f0f338 100644 --- a/tests/testthat/test-model-pathfinder.R +++ b/tests/testthat/test-model-pathfinder.R @@ -169,9 +169,9 @@ test_that("pathfinder() saves single path outputs", { ) ) - expect_equal(basename(fit$output_files()), "pathfinder-1.csv") + expect_equal(basename(fit$output_files()), "pathfinder-01.csv") single_path_files <- file.path(output_dir, paste0( - "pathfinder-1_path_", + "pathfinder-01_path_", rep(1:2, each = 2), c(".csv", ".json") )) diff --git a/tests/testthat/test-utils.R b/tests/testthat/test-utils.R index c38390498..f7d8a7c26 100644 --- a/tests/testthat/test-utils.R +++ b/tests/testthat/test-utils.R @@ -182,6 +182,18 @@ test_that("get_standalone_hpp() suggests formatting deprecated syntax", { # misc -------------------------------------------------------------------- +test_that("generate_file_names() zero-pads IDs", { + expect_equal( + generate_file_names( + basename = "output", + ids = c(1, 9, 10, 100), + timestamp = FALSE, + random = FALSE + ), + paste0("output-", c("01", "09", "10", "100"), ".csv") + ) +}) + test_that("copy_temp_files retains sources if any copy fails", { source_dir <- withr::local_tempdir() destination_dir <- withr::local_tempdir() From f509c7c548b1ed3acf701c7fb9cc07ee5df1f8c9 Mon Sep 17 00:00:00 2001 From: jgabry Date: Mon, 24 Aug 2026 17:40:25 -0600 Subject: [PATCH 2/3] Update NEWS.md --- NEWS.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/NEWS.md b/NEWS.md index b82a3e592..5f5ee08c4 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,6 +1,7 @@ # cmdstanr (development version) -* Chain IDs in generated filenames are now zero-padded to at least two digits, for example `01` instead of `1`. +* Chain IDs in generated filenames are now zero-padded to at least two digits, +for example `01` instead of `1`. (#1244) * When using CmdStan through WSL, paths for output, diagnostic, profile, config, and metric files now remain accessible to Windows R when an explicit output directory is supplied. (#1110; related: #1113) From 76ad433415db3d86ada0b1825388ed6acdda7437 Mon Sep 17 00:00:00 2001 From: jgabry Date: Tue, 25 Aug 2026 09:39:02 -0600 Subject: [PATCH 3/3] derive padding from largest ID --- R/utils.R | 3 ++- tests/testthat/test-utils.R | 18 +++++++++++++++--- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/R/utils.R b/R/utils.R index 002d92a65..aea85ab5b 100644 --- a/R/utils.R +++ b/R/utils.R @@ -266,7 +266,8 @@ generate_file_names <- new_names <- paste0(new_names, "-", stamp) } if (!is.null(ids)) { - new_names <- paste0(new_names, "-", sprintf("%02d", ids)) + width <- max(2L, nchar(sprintf("%d", max(ids)))) + new_names <- paste0(new_names, "-", sprintf("%0*d", width, ids)) } if (random) { rand_num_pid <- as.integer(stats::runif(1, min = 0, max = 1E7)) + Sys.getpid() diff --git a/tests/testthat/test-utils.R b/tests/testthat/test-utils.R index f7d8a7c26..1319d1e3b 100644 --- a/tests/testthat/test-utils.R +++ b/tests/testthat/test-utils.R @@ -182,16 +182,28 @@ test_that("get_standalone_hpp() suggests formatting deprecated syntax", { # misc -------------------------------------------------------------------- -test_that("generate_file_names() zero-pads IDs", { +test_that("generate_file_names() zero-pads IDs for lexicographic sorting", { expect_equal( generate_file_names( basename = "output", - ids = c(1, 9, 10, 100), + ids = 1:10, timestamp = FALSE, random = FALSE ), - paste0("output-", c("01", "09", "10", "100"), ".csv") + paste0("output-", sprintf("%02d", 1:10), ".csv") ) + + file_names <- generate_file_names( + basename = "output", + ids = 1:100, + timestamp = FALSE, + random = FALSE + ) + expect_equal( + file_names[c(1, 9, 10, 100)], + paste0("output-", c("001", "009", "010", "100"), ".csv") + ) + expect_equal(sort(file_names), file_names) }) test_that("copy_temp_files retains sources if any copy fails", {