From 18bccdbdf0ecbc77dfa8559de46e112f72a5be9c Mon Sep 17 00:00:00 2001 From: jgabry Date: Tue, 22 Sep 2026 14:06:17 -0600 Subject: [PATCH 1/2] Report the stanc flags make/local added in stan_build_info() The build record already held stanc_options_from_make, the STANCFLAGS that make/local contributed to the stanc call. The public result left it out with the other assembly fields. It is kept now for the same reason as include_paths: make/local can change after a build, so the file alone does not say which flags the build saw. Part of #1258. --- R/build_record.R | 17 ++++++++++------ dev-notes/compilation-state-contract.md | 1 + dev-notes/compilation-state.md | 10 +++++---- man/stan_build_info.Rd | 13 ++++++------ tests/testthat/test-build-info.R | 27 ++++++++++++++++++++++--- 5 files changed, 49 insertions(+), 19 deletions(-) diff --git a/R/build_record.R b/R/build_record.R index ddf75f9f6..f597d5888 100644 --- a/R/build_record.R +++ b/R/build_record.R @@ -681,9 +681,12 @@ assess_build <- function(expected, current) { #' * `stanc_options`: the flags as given to stanc, in order. #' For example, `list(O1 = TRUE)` and `list("O1")` both come back as #' `list("--O1")`. +#' * `stanc_options_from_make`: the flags `make/local` added to the stanc +#' call through `STANCFLAGS`. A flag that `stanc_options` also sets is not +#' repeated here since `stanc_options` takes precedence. #' * `include_paths`: a character vector of the directories searched for -#' included files. When none were given, this is the Stan program's own -#' directory if the program has includes and empty otherwise. +#' included files. When none were provided but the Stan program has includes +#' this is set to the program's own directory. #' #' * `dependencies`: A list describing the files the build read. Contains sublists #' `stan_file`, `included_files`, `user_header` and `make_local`. `user_header` @@ -710,10 +713,8 @@ assess_build <- function(expected, current) { #' was found in. #' #' The result leaves out some of what the record holds: the file hashes the -#' rebuild check compares, the stanc flags CmdStanR added or `make/local` -#' contributed, the model name given to stanc, and the TBB directory. -#' `dependencies$make_local` names the file those flags came from, though its -#' contents may have changed since the build. +#' rebuild check compares, the stanc flags CmdStanR added itself, the model +#' name given to stanc, and the TBB directory. #' #' Absent items and empty items have different interpretations. A field missing #' from the result means there was no usable record to read it from. An empty @@ -746,6 +747,7 @@ stan_build_info <- function(exe_file) { info$configuration <- list( cpp_options = record$configuration$cpp_options, stanc_options = record$configuration$stanc_options, + stanc_options_from_make = record$configuration$stanc_options_from_make, include_paths = as.character(unlist(record$configuration$include_paths)) ) info$dependencies <- public_dependencies(record$dependencies) @@ -847,6 +849,9 @@ print.stan_build_info <- function(x, ...) { stanc_options <- unlist(x$configuration$stanc_options) cat(" stanc_options: ", if (length(stanc_options) == 0) "none" else paste(stanc_options, collapse = " "), "\n", sep = "") + from_make <- unlist(x$configuration$stanc_options_from_make) + cat(" stanc_options_from_make: ", if (length(from_make) == 0) "none" else + paste(from_make, collapse = " "), "\n", sep = "") cat(" include_paths: ", paste(x$configuration$include_paths, collapse = ", "), "\n", sep = "") diff --git a/dev-notes/compilation-state-contract.md b/dev-notes/compilation-state-contract.md index 0222caf85..fd60e5941 100644 --- a/dev-notes/compilation-state-contract.md +++ b/dev-notes/compilation-state-contract.md @@ -980,6 +980,7 @@ list( configuration = list( cpp_options = list(STAN_THREADS = "true"), stanc_options = list(), + stanc_options_from_make = list("--O1"), include_paths = "/proj" ), dependencies = list( diff --git a/dev-notes/compilation-state.md b/dev-notes/compilation-state.md index 4297eb62e..1c0628375 100644 --- a/dev-notes/compilation-state.md +++ b/dev-notes/compilation-state.md @@ -2994,11 +2994,12 @@ shipped yet, so the set can still be chosen freely; after 1.0 it cannot. The has fail that test. The artifact hash answers a question the caller can already answer by hashing the file whose path they just passed in, and the dependency hashes compare against nothing but another record's same field. So do -`stanc_options_added`, `stanc_options_from_make` and `stanc_name`, which say how -cmdstanr assembled the stanc command line rather than what was asked of it. `tbb_dir` is out on the same +`stanc_options_added` and `stanc_name`, which say how cmdstanr assembled the stanc +command line rather than what was asked of it. `tbb_dir` is out on the same test; the record keeps it because Windows needs it at launch (ยง4). `configuration` keeps -`include_paths` for diagnosis alone: a caller debugging an include has no other way -to see where the build searched. +`include_paths` and `stanc_options_from_make` for diagnosis alone: a caller debugging +an include has no other way to see where the build searched, and `make/local` can +change after a build, so the file alone does not say which flags the build saw. @@ -3017,6 +3018,7 @@ list( configuration = list( cpp_options = list(STAN_THREADS = "true"), stanc_options = list(), + stanc_options_from_make = list("--O1"), include_paths = "/proj" ), dependencies = list( diff --git a/man/stan_build_info.Rd b/man/stan_build_info.Rd index fd346fc36..26cec963b 100644 --- a/man/stan_build_info.Rd +++ b/man/stan_build_info.Rd @@ -46,9 +46,12 @@ comes back as \code{list(STAN_THREADS = "true")}. \item \code{stanc_options}: the flags as given to stanc, in order. For example, \code{list(O1 = TRUE)} and \code{list("O1")} both come back as \code{list("--O1")}. +\item \code{stanc_options_from_make}: the flags \code{make/local} added to the stanc +call through \code{STANCFLAGS}. A flag that \code{stanc_options} also sets is not +repeated here since \code{stanc_options} takes precedence. \item \code{include_paths}: a character vector of the directories searched for -included files. When none were given, this is the Stan program's own -directory if the program has includes and empty otherwise. +included files. When none were provided but the Stan program has includes +this is set to the program's own directory. } \item \code{dependencies}: A list describing the files the build read. Contains sublists \code{stan_file}, \code{included_files}, \code{user_header} and \code{make_local}. \code{user_header} @@ -74,10 +77,8 @@ was found in. } The result leaves out some of what the record holds: the file hashes the -rebuild check compares, the stanc flags CmdStanR added or \code{make/local} -contributed, the model name given to stanc, and the TBB directory. -\code{dependencies$make_local} names the file those flags came from, though its -contents may have changed since the build. +rebuild check compares, the stanc flags CmdStanR added itself, the model +name given to stanc, and the TBB directory. Absent items and empty items have different interpretations. A field missing from the result means there was no usable record to read it from. An empty diff --git a/tests/testthat/test-build-info.R b/tests/testthat/test-build-info.R index 74a85ecc6..5b11235a9 100644 --- a/tests/testthat/test-build-info.R +++ b/tests/testthat/test-build-info.R @@ -53,10 +53,15 @@ test_that("an available build record is read in full without launching the execu expect_identical(features$stan_version, "2.39.0") expect_named( - result$configuration, c("cpp_options", "stanc_options", "include_paths") + result$configuration, + c( + "cpp_options", "stanc_options", "stanc_options_from_make", + "include_paths" + ) ) expect_equal(result$configuration$cpp_options, list(STAN_THREADS = "true")) expect_equal(result$configuration$stanc_options, list("--O1")) + expect_equal(result$configuration$stanc_options_from_make, list()) expect_type(result$configuration$include_paths, "character") expect_named( @@ -103,10 +108,25 @@ test_that("fields the record withholds are absent from the result", { expect_named(result$dependencies$make_local, c("built_from", "exists")) expect_false("stanc_options_added" %in% names(result$configuration)) - expect_false("stanc_options_from_make" %in% names(result$configuration)) expect_false("stanc_name" %in% names(result$configuration)) expect_named( - result$configuration, c("cpp_options", "stanc_options", "include_paths") + result$configuration, + c( + "cpp_options", "stanc_options", "stanc_options_from_make", + "include_paths" + ) + ) +}) + +test_that("the stanc flags make/local added come back as recorded", { + result <- available_result(function(record) { + record$configuration$stanc_options_from_make <- + list("--O0", "--warn-pedantic") + record + }) + expect_equal( + result$configuration$stanc_options_from_make, + list("--O0", "--warn-pedantic") ) }) @@ -480,6 +500,7 @@ test_that("print.stan_build_info() shows the fragments the spec pins", { configuration = list( cpp_options = list(STAN_THREADS = "true"), stanc_options = list("--O1"), + stanc_options_from_make = list(), include_paths = character(0) ), dependencies = list( From 99ac5754b7ce85138e08851d215c9a42964d1931 Mon Sep 17 00:00:00 2001 From: jgabry Date: Tue, 22 Sep 2026 14:43:36 -0600 Subject: [PATCH 2/2] Show stan_build_info() in the internals vignette and tidy its printer The build record section of the vignette now reads the record back with $build_info() and points at stan_build_info() for a bare executable path. The example copies bernoulli into tempdir() under its own name, so the paths and the record file read as bernoulli. The printer says "none" for empty include_paths as it does for the other option lines, and the status lines for a record that is missing, unreadable, mismatched or in another format fit in 80 columns and say what happened and what to do. One snapshot test covers the printed form of every record state. Part of #1258. --- R/build_record.R | 31 ++++---- man/stan_build_info.Rd | 4 +- tests/testthat/_snaps/build-info.md | 114 ++++++++++++++++++++++++++++ tests/testthat/test-build-info.R | 69 ++++++++++++----- vignettes/cmdstanr-internals.Rmd | 25 +++++- 5 files changed, 204 insertions(+), 39 deletions(-) create mode 100644 tests/testthat/_snaps/build-info.md diff --git a/R/build_record.R b/R/build_record.R index f597d5888..22bfa253a 100644 --- a/R/build_record.R +++ b/R/build_record.R @@ -659,8 +659,8 @@ assess_build <- function(expected, current) { #' `"unavailable"`, and `reason`, which is `NULL` when the record is available #' and otherwise one of `"missing"` (no record beside the executable), #' `"unreadable"` (a record that could not be read), `"executable_mismatch"` -#' (the record describes a different executable, so the one at this path was -#' replaced after the record was written) or `"unsupported_format"` (written by +#' (the record describes a different executable, so the one at this path has +#' changed since the record was written) or `"unsupported_format"` (written by #' a CmdStanR that stores records differently, in which case the result also #' has a `format_version` field). #' @@ -852,8 +852,9 @@ print.stan_build_info <- function(x, ...) { from_make <- unlist(x$configuration$stanc_options_from_make) cat(" stanc_options_from_make: ", if (length(from_make) == 0) "none" else paste(from_make, collapse = " "), "\n", sep = "") - cat(" include_paths: ", paste(x$configuration$include_paths, collapse = ", "), - "\n", sep = "") + include_paths <- x$configuration$include_paths + cat(" include_paths: ", if (length(include_paths) == 0) "none" else + paste(include_paths, collapse = ", "), "\n", sep = "") cat("Dependencies:\n") path_line <- function(label, entry) { @@ -895,24 +896,24 @@ build_record_status_line <- function(x) { } switch(x$record$reason, missing = paste0( - "Build record: none. There is no build record beside this executable, ", - "so only what it reports about itself is known." + "Build record: not found. Only what the executable says about itself ", + "is known." ), - unreadable = "Build record: could not be read.", - executable_mismatch = paste0( - "Build record: does not match this executable, which was replaced ", - "after the record was written." + unreadable = paste0( + "Build record: could not be read. Rebuilding the executable writes a ", + "new one." ), + executable_mismatch = + "Build record: does not match. Executable changed after the build.", unsupported_format = if (x$format_version > build_record_format_version) { paste0( - "Build record: written in format ", x$format_version, " by a newer ", - "version of CmdStanR. Upgrade CmdStanR to read it." + "Build record: format ", x$format_version, " (newer CmdStanR). ", + "Upgrade CmdStanR to read it." ) } else { paste0( - "Build record: written in format ", x$format_version, " by an older ", - "version of CmdStanR. To get a record this version reads, rebuild ", - "the executable." + "Build record: format ", x$format_version, " (older CmdStanR). ", + "Rebuild the executable to replace it." ) } ) diff --git a/man/stan_build_info.Rd b/man/stan_build_info.Rd index 26cec963b..050f6ea02 100644 --- a/man/stan_build_info.Rd +++ b/man/stan_build_info.Rd @@ -23,8 +23,8 @@ A list of class \code{"stan_build_info"}. Two fields are always there: \code{"unavailable"}, and \code{reason}, which is \code{NULL} when the record is available and otherwise one of \code{"missing"} (no record beside the executable), \code{"unreadable"} (a record that could not be read), \code{"executable_mismatch"} -(the record describes a different executable, so the one at this path was -replaced after the record was written) or \code{"unsupported_format"} (written by +(the record describes a different executable, so the one at this path has +changed since the record was written) or \code{"unsupported_format"} (written by a CmdStanR that stores records differently, in which case the result also has a \code{format_version} field). \item \code{reported_features}: A list containing what the executable reports about diff --git a/tests/testthat/_snaps/build-info.md b/tests/testthat/_snaps/build-info.md new file mode 100644 index 000000000..d3564e3ea --- /dev/null +++ b/tests/testthat/_snaps/build-info.md @@ -0,0 +1,114 @@ +# print.stan_build_info() output for each record state + + Code + print(full_x) + Output + Build record: available + Reported features: + stan_threads: TRUE + stan_mpi: FALSE + stan_opencl: FALSE + stan_no_range_checks: unknown + stan_version: 2.39.0 + Configuration: + cpp_options: STAN_THREADS=true STAN_CPP_OPTIMS=true + stanc_options: --O1 + stanc_options_from_make: --warn-pedantic + include_paths: /proj/inc, /proj/shared + Dependencies: + stan_file: /proj/bernoulli.stan + included_file: /proj/inc/half.stan + included_file: /proj/shared/prior.stan (no longer exists) + user_header: /proj/helpers.hpp + make_local: /opt/cmdstan-2.39.0/make/local + CmdStan 2.39.0 at /opt/cmdstan-2.39.0 + Dependencies CmdStanR does not track: + make/local includes another makefile (/opt/cmdstan-2.39.0/make/local) + the user header includes other headers (/proj/helpers.hpp) + +--- + + Code + print(sparse_x) + Output + Build record: available + Reported features: + stan_threads: TRUE + stan_mpi: unknown + stan_opencl: FALSE + stan_no_range_checks: unknown + stan_version: 2.39.0 + Configuration: + cpp_options: STAN_THREADS=true + stanc_options: --O1 + stanc_options_from_make: none + include_paths: none + Dependencies: + stan_file: bernoulli.stan (no longer exists) + CmdStan 2.39.0 at /opt/cmdstan-2.39.0 (no longer exists) + +--- + + Code + print(missing_x) + Output + Build record: not found. Only what the executable says about itself is known. + Reported features: + stan_threads: unknown + stan_mpi: unknown + stan_opencl: unknown + stan_no_range_checks: unknown + stan_version: unknown + +--- + + Code + print(unreadable_x) + Output + Build record: could not be read. Rebuilding the executable writes a new one. + Reported features: + stan_threads: unknown + stan_mpi: unknown + stan_opencl: unknown + stan_no_range_checks: unknown + stan_version: unknown + +--- + + Code + print(mismatch_x) + Output + Build record: does not match. Executable changed after the build. + Reported features: + stan_threads: unknown + stan_mpi: unknown + stan_opencl: unknown + stan_no_range_checks: unknown + stan_version: unknown + +--- + + Code + print(newer_x) + Output + Build record: format 2 (newer CmdStanR). Upgrade CmdStanR to read it. + Reported features: + stan_threads: unknown + stan_mpi: unknown + stan_opencl: unknown + stan_no_range_checks: unknown + stan_version: unknown + +--- + + Code + print(older_x) + Output + Build record: format 0 (older CmdStanR). Rebuild the executable to replace it. + Reported features: + stan_threads: unknown + stan_mpi: unknown + stan_opencl: unknown + stan_no_range_checks: unknown + stan_version: unknown + diff --git a/tests/testthat/test-build-info.R b/tests/testthat/test-build-info.R index 5b11235a9..1cb38bc2a 100644 --- a/tests/testthat/test-build-info.R +++ b/tests/testthat/test-build-info.R @@ -484,13 +484,13 @@ test_that("features reported by the binary have the fixed shape", { expect_identical(features$stan_version, "2.39.0") }) -test_that("print.stan_build_info() shows the fragments the spec pins", { +test_that("print.stan_build_info() output for each record state", { unavailable_features <- list( stan_threads = NA, stan_mpi = NA, stan_opencl = NA, stan_no_range_checks = NA, stan_version = NA_character_ ) - available_x <- structure( + sparse_x <- structure( list( record = list(status = "available", reason = NULL), reported_features = list( @@ -509,10 +509,10 @@ test_that("print.stan_build_info() shows the fragments the spec pins", { user_header = NULL, make_local = NULL ), - cmdstan = list(path = "/opt/cmdstan-2.39.0", version = "2.39.0", exists = FALSE), - untracked_dependencies = list( - list(kind = "make_local_include", detected_in = "make/local") - ) + cmdstan = list( + path = "/opt/cmdstan-2.39.0", version = "2.39.0", exists = FALSE + ), + untracked_dependencies = list() ), class = "stan_build_info" ) @@ -554,17 +554,50 @@ test_that("print.stan_build_info() shows the fragments the spec pins", { class = "stan_build_info" ) - expect_output(print(available_x), "available", fixed = TRUE) - expect_output(print(missing_x), "no build record", fixed = TRUE) - expect_output(print(unreadable_x), "could not be read", fixed = TRUE) - expect_output(print(mismatch_x), "does not match", fixed = TRUE) - expect_output(print(newer_x), "newer version of CmdStanR", fixed = TRUE) - expect_output(print(older_x), "rebuild", fixed = TRUE) - expect_output(print(available_x), "stan_threads: TRUE", fixed = TRUE) - expect_output(print(available_x), "stan_mpi: unknown", fixed = TRUE) - expect_output(print(available_x), "no longer exists", fixed = TRUE) - expect_output( - print(available_x), "make/local includes another makefile", fixed = TRUE + full_x <- structure( + list( + record = list(status = "available", reason = NULL), + reported_features = list( + stan_threads = TRUE, stan_mpi = FALSE, stan_opencl = FALSE, + stan_no_range_checks = NA, stan_version = "2.39.0" + ), + configuration = list( + cpp_options = list(STAN_THREADS = "true", STAN_CPP_OPTIMS = "true"), + stanc_options = list("--O1"), + stanc_options_from_make = list("--warn-pedantic"), + include_paths = c("/proj/inc", "/proj/shared") + ), + dependencies = list( + stan_file = list(built_from = "/proj/bernoulli.stan", exists = TRUE), + included_files = list( + list(built_from = "/proj/inc/half.stan", exists = TRUE), + list(built_from = "/proj/shared/prior.stan", exists = FALSE) + ), + user_header = list(built_from = "/proj/helpers.hpp", exists = TRUE), + make_local = list( + built_from = "/opt/cmdstan-2.39.0/make/local", exists = TRUE + ) + ), + cmdstan = list( + path = "/opt/cmdstan-2.39.0", version = "2.39.0", exists = TRUE + ), + untracked_dependencies = list( + list( + kind = "make_local_include", + detected_in = "/opt/cmdstan-2.39.0/make/local" + ), + list(kind = "user_header_include", detected_in = "/proj/helpers.hpp") + ) + ), + class = "stan_build_info" ) - expect_output(expect_invisible(print(available_x))) + + expect_snapshot(print(full_x)) + expect_snapshot(print(sparse_x)) + expect_snapshot(print(missing_x)) + expect_snapshot(print(unreadable_x)) + expect_snapshot(print(mismatch_x)) + expect_snapshot(print(newer_x)) + expect_snapshot(print(older_x)) + expect_output(expect_invisible(print(sparse_x))) }) diff --git a/vignettes/cmdstanr-internals.Rmd b/vignettes/cmdstanr-internals.Rmd index 741614db9..ef4b7b11e 100644 --- a/vignettes/cmdstanr-internals.Rmd +++ b/vignettes/cmdstanr-internals.Rmd @@ -39,13 +39,15 @@ The `cmdstan_model()` function creates a new `CmdStanModel` object, which stores the path to a Stan program as well as the path to a compiled executable. ```{r compile} -# Copy the example to a tempfile so the vignette doesn't modify -# the CmdStan installation. +# Copy the example to the temporary directory so the vignette doesn't modify +# the CmdStan installation example_stan_file <- file.path( cmdstan_path(), "examples", "bernoulli", "bernoulli.stan" ) -stan_file <- tempfile(pattern = "bernoulli-", fileext = ".stan") +stan_file <- file.path(tempdir(), "bernoulli.stan") invisible(file.copy(example_stan_file, stan_file)) + +# Compile the model mod <- cmdstan_model(stan_file) mod$print() mod$stan_file() @@ -252,7 +254,22 @@ itself. The file is named after the executable, so `bernoulli` is described by `.bernoulli.cmdstanr.json`. ```{r build-record} -list.files(dirname(mod$exe_file()), pattern = "cmdstanr.json", all.files = TRUE) +list.files( + dirname(mod$exe_file()), pattern = "bernoulli.*cmdstanr.json", all.files = TRUE +) +``` + +The `$build_info()` method reads the record back. The standalone +`stan_build_info()` function does the same for a path to an executable when you +don't have a model object. When there is no usable record it says why and +reports only what the executable says about itself. + +```{r build-info} +mod$build_info() + +# demonstrating stan_build_info even though here we have the model object +# so we can use mod$build_info() +stan_build_info(mod$exe_file()) ``` If the directory is under version control, we recommend adding the record file