Refresh model state on recompilation and stop dropping compile-time inputs - #1235
Draft
jgabry wants to merge 21 commits into
Draft
Refresh model state on recompilation and stop dropping compile-time inputs#1235jgabry wants to merge 21 commits into
jgabry wants to merge 21 commits into
Conversation
A CmdStanModel kept describing the program it was created from after the Stan file was edited and the same object recompiled. private$stan_code_ was read once in initialize() and only ever refreshed by $format(overwrite_file = TRUE), and private$variables_ was populated lazily by $variables() and never invalidated. This was not only cosmetic: the fitting methods pass self$variables() into the data and init checks, so a recompiled model validated against the old parameter set and warned about parameters that no longer existed. Two adjacent pieces of state had the same problem. self$functions had its hpp_code overwritten before the make call while the compiled flag, the function names and the old Rcpp bindings survived, so expose_stan_functions() short-circuited on compiled and kept serving the previous implementations. private$using_user_header_ was only ever set to TRUE, before compilation ran, and never reset. Make successful replacement of the executable the synchronization point. Everything derived from the Stan program is now committed in one block after the exe copy: the code snapshot is taken from the temp file that was actually compiled, variables_ is cleared so the next $variables() reparses lazily, using_user_header_ is set from the arguments resolved for this compilation in both directions, and the functions environment is emptied in place and repopulated. Clearing it in place preserves its identity, and existing fit objects are unaffected because CmdStanFit copies the contents into its own environment at construction. The standalone hpp and the external/existing_exe values are assigned to locals instead of being written into self$functions early, and the compile_standalone exposure moves from before the make call to after the commit block. That is what makes a failed compilation atomic: a dry run, a stanc failure or a C++ failure now all leave the previously compiled state untouched. Two consequences. $compile(dry_run = TRUE) no longer writes anything into self$functions. After a real recompilation with compile_standalone = FALSE previously exposed functions are gone and must be exposed again. fixes #1228
Compile-time inputs supplied to cmdstan_model() or $compile() were consumed by a single compilation and then forgotten: $compile() cleared the precompile_* fields at the end and nothing fed include_paths_ back in, so a second $compile() through the same object ran with no include paths and no user header. A model using #include directives or a user header could not be recompiled at all, and a header that overrides an existing definition rather than supplying an undeclared one produced a different executable with no error at all. Include paths and a user header are not build options, they are inputs the program needs in order to translate, so they now persist for the life of the model object and are replaced whenever new ones are supplied. cpp_options and stanc_options keep their one-shot behavior: a bare $compile() producing an unconfigured build is a tested workflow, and sticky stanc_options would leak values such as a stanc name= into every later compilation of the same object. $compile() now falls back to include_paths_ and then to precompile_include_paths_, and a fourth branch of the existing user header chain reuses the stored header when neither the argument nor a cpp_options entry is given. Putting it in that chain keeps the "specified both via" warnings from firing on a reused header. The header is committed with the rest of the compiled state, so a failed compilation does not record a header it never used. cmdstan_model() now stores the user_header argument. It was only passed through to $compile(), so with compile = FALSE it was lost entirely and even the first $compile() failed, while using_user_header_ still claimed the model had a header. The three precompile_* <- NULL assignments move inside if (!dry_run). Clearing them ran even when nothing had been compiled, which discarded the options given to cmdstan_model() and was also what kept a user header supplied through cpp_options from surviving a dry run. $include_paths() no longer gates on the executable existing. It returned NULL after $compile(dry_run = TRUE) or once the executable had been removed, and $variables(), $check_syntax() and $format() all read it. fixes #1234
$check_syntax() and $format() build their stanc arguments from precompile_stanc_options_ and never consulted using_user_header_, so a model with a function that is declared in the Stan program and defined in a user header was reported as a syntax error. $compile() derives --allow-undefined from the resolved user header and $variables() derives it from using_user_header_; these two methods were the only ones that did not. The failure does not depend on the model having been compiled: it happens on a model created with compile = FALSE as well, so it is not a consequence of the compile-time options being consumed once.
$check_syntax() and $format() build their stanc arguments from precompile_stanc_options_ and never consulted using_user_header_, so a model with a function that is declared in the Stan program and defined in a user header was reported as a syntax error. $compile() derives --allow-undefined from the resolved user header and $variables() derives it from using_user_header_; these two methods were the only ones that did not. The failure does not depend on the model having been compiled: it happens on a model created with compile = FALSE as well, so it is not a consequence of the compile-time options being consumed once.
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## master #1235 +/- ##
==========================================
+ Coverage 91.73% 91.81% +0.07%
==========================================
Files 15 15
Lines 6281 6473 +192
==========================================
+ Hits 5762 5943 +181
- Misses 519 530 +11 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
with_mocked_cli() returned status 0 without writing anything to the path make was given, so any code that installs the compiled artifact had nothing to install and no test could observe it failing. The mock now creates that file, and only when the mocked compile succeeds, since a failed make must not leave one behind. The isTRUE() guard is needed because existing callers pass compile_ret = list(), where a bare comparison would be if (logical(0)) and error. args[1] is a WSL-safe path, so it is converted back before use. That makes the destination of a mocked compile matter. The tests in test-model-recompile-logic.R compiled the CmdStan installation's own bernoulli example in place, which a faithful mock overwrites with an empty file; they now work on a temporary copy. Since that executable is not part of the repository and a truncating overwrite leaves no diff at all, the file also carries a guard test comparing its size and mtime before and after.
Successful replacement of the executable is the point at which state describing the compiled artifact may be committed, but several mutations still happened before the make call or on paths where nothing was compiled at all. The model-method environment and the generated .hpp path were assigned before the compiler ran, so a failure at the C++ stage left the old executable paired with model-method code generated from the new source. That environment is handed to every fit, so fit$init_model_methods() would compile log_prob() from a program the draws did not come from. Both are now staged in locals and committed with everything else, along with reading the Stan source, and the model-method header is written before the executable is replaced rather than after. A compile that finds the executable up to date compiles nothing, so it may no longer consume or overwrite what describes the current executable. It previously replaced cpp_options_ with whatever the call supplied, which erased stan_threads and made assert_valid_threads() run a threaded executable single-threaded; it cleared the precompile options a later forced recompilation needs; and it asserted existing_exe unconditionally, so $expose_functions() failed on a model that had compiled itself. When the object is instead adopting an executable it did not build, the options are recovered from the binary itself on a best-effort basis, reusing the filtering the constructor already did. Resolving to a different executable than the object describes now forces compilation rather than adopting it, since keeping this object's generated C++ alongside another binary is the same hybrid. Paths are compared canonically so symlink aliases and Windows casing do not cause needless rebuilds.
The old executable was removed and the new one copied over it with both return values discarded, so a copy that failed after a successful remove left the model with no executable and no error. The file.remove() had no recorded rationale; it was added in 2021 in a commit titled "fix syntax" with an empty body. install_executable() stages the new executable beside the destination, moves any existing one to a sibling backup, and only then renames the staged copy into place, restoring the backup if that rename fails. Both temporary names come from tempfile() rather than fixed .new/.bak suffixes, which would collide with stale files and parallel builds. WSL's chmod +x moves onto the staged candidate and has its status checked, since an unchecked chmod after installation is another boundary where the executable is in place but not safely committed. Every filesystem call is wrapped in suppressWarnings() and checked by value. file.copy() and file.rename() warn on failure, so under options(warn = 2) base throws before returning FALSE, and on the candidate-to-destination rename that would skip the rollback entirely and strand the only good executable at the backup path. unlink() reports a status without signalling, so it needs no such treatment, but it returns 0L rather than TRUE. A backup that cannot be removed after a successful install is returned, not signalled. Under warn = 2 a warning here would unwind before the caller could record the state describing the executable just installed, which is precisely the hybrid this work exists to prevent, so the caller warns only after the optional exposure work has run. This is staged and rollback-capable rather than transactional: a crash between the two renames can still leave only the backup.
The header precedence lived in a four-branch chain inside compile() and was insufficient in two ways. cpp_options may already have been repopulated from precompile_cpp_options_ by the time it ran, so an explicit user_header = NULL still selected an inherited USER_HEADER; and cmdstan_model(compile = FALSE) never enters compile() at all, so constructing a model with an explicit NULL alongside a cpp_options header silently kept the header and built with it. The precedence is now a small pure resolver called from both initialize() and compile(). An explicit non-NULL argument wins; an explicit NULL clears both cpp_options spellings; only an omitted argument consults cpp_options and then the stored header. Supplied-ness is captured before anything is reassigned, since user_header = NULL is also the default and cannot otherwise be told from an omitted argument -- with missing() in compile() and, for arguments arriving through ..., with names(), which list(...) preserves for NULL entries. Warnings are emitted at the call sites so a model compiled at construction warns once rather than twice. Both spellings are reduced to the one actually used, so $cpp_options() no longer reports the ignored duplicate. A header changing identity now forces compilation, through a dirty flag rather than by inferring it from cpp_options_: a compile through the lowercase spelling never leaves USER_HEADER behind, stored options are WSL-safe paths while user_header is deliberately a host path, and an absent entry conflates "no header" with "unknown". The flag is latched rather than assigned, because on a bare retry after a failed compile the reuse branch resolves back to the same header and nothing looks changed. It is cleared only by a successful executable replacement. user_header_ and using_user_header_ are configuration for the next invocation rather than a description of the executable, so they are assigned as soon as they are validated. A failed compile with a new header is usually a bug in that header, and a bare retry after fixing it must build the header the user supplied. This also stops a failed compile from leaving using_user_header_ FALSE, which made $check_syntax() report the bogus "declared without specifying a definition" error again. Shape is validated wherever a header is accepted, so character(0) is rejected informatively, while existence is checked only when compiling, keeping a header created between construction and $compile() working.
A dry run builds nothing, so it no longer records cpp_options_ or moves hpp_file_; the latter previously pointed $hpp_file() at a temporary file the dry run never wrote. exe_file_ and cmdstan_version_ stay in the tail, commented as the deliberate exceptions: during a dry run they are also the configured destination and the toolchain version, so they are assigned on dry runs and on success but never on a failure. cmdstan_version() is now evaluated into a local before any compilation work rather than after the executable is installed. It is not infallible despite being an accessor: set_cmdstan_path() stores PATH and VERSION together, and when read_cmdstan_version() returns NULL the guard falls through and leaves PATH set with VERSION NULL. In that state stanc and make both run and only this call errors. It cannot be hoisted any higher, since an ordinary no-op returns before ever reaching it and would gain a failure mode it does not have today. If discarding the staged candidate fails while another error is being raised, the diagnostic now names the leftover path instead of implying it was removed. The two tests that asserted on $cpp_options() after a dry run move to mocked successful compiles, and the header precedence test asserts that the ignored spelling is dropped rather than retained.
The resolver strips both header spellings from cpp_options and only $compile() reinserts the selected one, so precompile_cpp_options_ never carries a header. That is deliberate but not self-evident: storing it there would store a WSL-safe path, which the next $compile() would then select as its user_header, and that is a host path by design because file.exists() on a WSL-safe path fails under WSLv1. Also records in NEWS that a dry run no longer sets $cpp_options(), alongside the existing note about $hpp_file().
A compile that finds the executable up to date and is adopting one it did not build described it only by what the binary reports about itself, dropping the cpp_options the call asked for. Constructing a model over an already-compiled, unthreaded executable with stan_threads = TRUE therefore left stan_threads unset, so assert_valid_threads() discarded threads_per_chain and the model ran single-threaded without the caller ever asking for that. The options are now seeded from the request and filled in from the binary. Nothing is overwritten, since an object adopting an executable holds no options yet. Describing the executable purely by its own metadata would be the more honest answer, but cmdstanr does not yet rebuild when the requested options disagree with the binary -- the tests covering that are still skipped as "to be fixed in a later version" -- so until it does, the request is part of how an adopted executable is described.
jgabry
marked this pull request as draft
July 28, 2026 01:36
Seeding the options from the request covered only the case where the object was adopting an executable it did not build. Calling $compile(cpp_options = list(stan_threads = TRUE)) on an object that already describes an up-to-date executable took the other branch, which preserves the recorded options and so ignored the request just the same. Both branches now share one rule: options supplied to this call are recorded, since they are the caller's declared intent and cmdstanr does not yet rebuild when they disagree with the executable; a bare $compile() supplies none and must not erase what is already recorded, which is the erasure that made a threaded executable run single-threaded.
Supplying cpp_options to a compile that finds the executable up to date records them without rebuilding anything, so a requested stan_threads produced the "N thread(s) per chain" message while the binary, compiled without STAN_THREADS, ran single-threaded. The options were reported as though they applied. Rebuilding on a mismatch is the real fix and is still outstanding, so until then say plainly that they had no effect and point at force_recompile = TRUE. This wires up exe_info_reflects_cpp_options(), which existed and was tested but had no caller. It compares lower-case names while model_compile_info() reports upper-case ones, so feeding it the current parser's output finds no overlap and always reports agreement; the names are aligned before the comparison. The check runs only when this call supplied cpp_options and the executable could be queried, so ordinary reuse stays quiet. Tests cover both routes that reach it, a fresh object adopting an executable and a second $compile() on the object that built one, and that no warning is raised when the executable already has the requested options.
The snapshot transforms matched the fixture directory literally, which holds only on platforms with one path separator. On Windows the paths in these diagnostics arrive with a mixture: dirname() converts to forward slashes while withr::local_tempdir() and tempfile() use backslashes, so tempfile(tmpdir = dirname(to)) produces "C:/a/b\exe-new-1234". The directory prefix then failed to match and the staged and backup names appeared in full, and where the prefix did match the separator in front of the random name still differed. Separators are normalized before the substitutions, which leaves the recorded snapshots unchanged on platforms that already agree.
Removing the early self$exe_file(exe) left compile_standalone's call to expose_stan_functions() ahead of the assignment in the tail, so a failure there installed the executable and then returned an object that could not find it. A later $compile() would find that executable up to date, take the adoption branch because exe_file_ was still empty, and set existing_exe, after which $expose_functions() refused permanently. Both optional exposures now run after every field describing the installed executable is committed. The cpp_options mismatch warning moves after the no-op branch records cpp_options_ and exe_file_, for the reason the leftover-backup warning is raised last: under options(warn = 2) it is an error, and raising it earlier unwound with the object half-updated. That warning, and the decision to record the requested options at all, now key off whether options are available rather than whether they arrived with this call. Options held from cmdstan_model(compile = FALSE) are equally the caller's intent, and were being discarded; the supplied-ness flag remains for the narrower question of whether a header conflict occurred within a single call. unlink() glob-expands by default, unlike the file.remove() it replaced, so a model directory containing [, ], * or ? matched nothing and reported success while a full copy of the previous executable stayed on disk. Both call sites pass expand = FALSE.
tempfile() joins with a backslash on Windows, so staging beside a WSL destination produced "//wsl$/distro/path/to/dir\exe-new-1234". The Win32 calls tolerate the mixed separators, but wsl_safe_path() only rewrites the prefix, so the POSIX chmod inside WSL was handed a path that does not exist and every real compile failed. The previous code chmod'ed the destination, which had been through repair_path() already, and ignored the status besides, so this only surfaced once the staged candidate became the thing being made executable and its status was checked. Both temporary paths now go through repair_path(). That also collapses the duplicated separator withr::local_tempdir() can return, so the snapshot transforms match every spelling of the fixture directory rather than the one literal form. Also clears variables_ in format(overwrite_file = TRUE). The program on disk is rewritten and stan_code_ reloaded from it, but anything already parsed stayed cached, so $code() and $variables() could describe different programs and the fitting methods validate data and initial values against $variables().
The snapshot transforms normalized backslashes out of the diagnostics. That was added to make the tests pass on Windows before the paths install_executable() builds were repaired, and it outlived its reason: with those paths repaired, a backslash reaching one of these messages is the WSL regression itself, and normalizing it away meant no snapshot could ever catch it. Only the fixture directory is still normalized, and only in the value being matched rather than in the message, because withr::local_tempdir() and repair_path() disagree about a duplicated separator.
The default-warn companion to the warn = 2 test asserted only that a warning was raised and that the object described the new program, which the signalling implementation this design rejected would also satisfy. Reporting the backup rather than deleting it is worth something only if the path named is real and still holds the previous executable, so the test now takes the path out of the message and checks it, rather than trusting that some path was mentioned.
expose_stan_functions() rejects WSL before it consults existing_exe, so the expose_functions() call asserting a self-built model is not marked pre-compiled errored there no matter what the compile logic recorded. The two assertions it backs up still run on WSL; only the observable consequence is guarded, matching the file-level skip in test-model-expose-functions.R.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Submission Checklist
Summary
Fixes #1228
Fixes #1234
This PR was generated in collaboration with claude code, some code was written by me and some by claude. All code was reviewed by me. I asked claude to generate the summary below.
Issue 1228 — source-derived state is refreshed on recompilation
private$stan_code_ was read once in initialize() and private$variables_ was cached on the first $variables() call; $compile() invalidated neither. Editing the .stan file and recompiling through the same object left $code() and $variables() describing the old program. That isn't only cosmetic — the fitting methods pass self$variables() into the data and init checks, so a recompiled model validated against the old parameter set and warned about parameters that no longer exist.
Successful replacement of the executable is now the synchronization point. In one block after the exe copy: the code snapshot is re-read from the temp file that was actually compiled, variables_ is cleared so the next $variables() reparses, the functions environment is emptied in place (preserving its identity) and repopulated. (using_user_header_ was committed here too; it is now assigned eagerly — see the follow-up section below.) The standalone hpp and the external/existing_exe values moved to locals, and the compile_standalone exposure moved to after that block — that's what makes a dry run or a failed compilation leave the previously compiled state untouched.
Two user-visible consequences:
$code() is still a snapshot: editing or deleting the source file alone doesn't change it. $variables() still parses the file on disk, so the two can diverge after an edit until the next compile — that's deliberate, since $variables() is useful on uncompiled models.
Issue 1234 — include paths and the user header persist
The precompile_* fields were cleared at the end of $compile() and nothing fed include_paths_ back in, so a second $compile() ran with no include paths and no user header. A model with #include directives or a user header could not be recompiled at all, and a header that overrides an existing definition rather than supplying an undeclared one would silently produce a different executable.
Include paths and a user header aren't build options — they're inputs the program needs in order to translate — so they now persist for the life of the object and are replaced whenever new ones are supplied:
cpp_options and stanc_options deliberately keep their one-shot behavior. A bare $compile() producing an unconfigured build is a tested workflow ("switching threads on and off works without rebuild"), and sticky stanc options would leak values such as name= into every later compilation of the same object. A test pins that asymmetry.
Also
$check_syntax() and $format() never consulted using_user_header_, so any model with an external C++ function was reported as a syntax error — even one that was never compiled. $compile() and $variables() both already derive --allow-undefined; these two were the only methods that didn't.
Testing
New regression tests in test-model-variables.R (the issue's reproduction, including that inits for the new parameter no longer trigger the "subset of parameters" message), test-model-code-print.R, test-model-expose-functions.R, test-model-compile.R (commit timing under dry_run and a failed compile, include-path reuse, and the cpp/stanc asymmetry) and test-model-compile-user_header.R (header reuse, and a header supplied to cmdstan_model()). Each was confirmed to fail before the corresponding fix.
Run locally on macOS: test-model-compile.R, test-model-compile-user_header.R, test-model-variables.R, test-model-code-print.R, test-model-recompile-logic.R, test-model-expose-functions.R, test-model-methods.R, plus the include-path test in test-fit-shared.R — all pass, with only pre-existing skips. The full suite will be run on CI and wasn't run locally because test-install.R rebuilds CmdStan from source.
Not included
After a real compilation, $check_syntax() and $format() still lose the stanc_options supplied to cmdstan_model(), since precompile_stanc_options_ is their only source. Fixing that means giving them a persistent copy rather than sharing the field $compile() consumes; noted in #1234.
Follow-up: applying the invariant everywhere
Review found the invariant above — successful replacement of the executable is
the point at which state describing the compiled artifact is committed — was
right but incompletely applied. State now splits three ways:
(
include_paths_,user_header_,using_user_header_). Assigned eagerly:a failed compile must not invalidate it, and
$variables(),$check_syntax()and
$format()consume it without compiling. Shape is validated wherever avalue is accepted; existence only when compiling, so a header created between
cmdstan_model(..., compile = FALSE)and$compile()still works. This alsocloses a hole above: a failed
$compile(user_header = h)used to leaveusing_user_header_FALSE, reintroducing the bogus "declared withoutspecifying a definition" error.
stan_code_,variables_,functions,model_methods_env_,hpp_file_,cpp_options_).Committed only after verified replacement.
exe_file_andcmdstan_version_are commented exceptions: during a dry run they are also the configured
destination and toolchain version, so they are assigned on dry runs and on
success, never on failure.
user_header_dirty_, which is neither: it records thatconfiguration and artifact have drifted apart.
Edge cases introduced by the new persistence
Persisting the header is what fixes #1234, and it raises two questions the old
code never had to answer.
compile = FALSEwas left unresolved, so alater
$compile()from a different working directory looked for it in thewrong place. Headers now resolve to absolute paths at construction, matching the
include-path convention from Relative include_paths are resolved against the working directory at each stanc call #1229.
$compile()nowaccepts
user_header = NULL, which clears a header supplied through any of thethree routes (
user_header,cpp_options$USER_HEADER,cpp_options$user_header). Because clearing changes what would be built, itforces recompilation rather than taking the up-to-date path — as does changing
from one header to another. Duplicate spellings in
cpp_optionsare reduced tothe one actually used, so
$cpp_options()no longer reports the ignoredduplicate after a successful compile.
The same precedence now runs at construction, not just in
$compile(): previouslycmdstan_model(f, compile = FALSE, user_header = NULL, cpp_options = list(USER_HEADER = h))silently ignored the explicitNULLand built withh.The user-header chain is now a single pure resolver called from both
initialize()andcompile().character(0)is rejected with a message namingthe argument instead of failing later with "invalid 'file' argument".
A quiet lie this surfaced
Supplying
cpp_optionsto a compile that finds the executable up to date recordsthem without rebuilding anything. So
cpp_options = list(stan_threads = TRUE)over an existing unthreaded executable made
$cpp_options()report threading andprinted
", with 2 thread(s) per chain..."— a message cmdstanr emits itself —while the binary, compiled without
STAN_THREADS, ran single-threaded. Resultswere correct; the performance the user asked for silently never happened.
$compile()now warns in that case and points atforce_recompile = TRUE.Rebuilding automatically on a mismatch is the real fix and remains outstanding
(the three
skip("To be fixed in a later version")tests intest-model-recompile-logic.R); this only stops the package from assertingsomething untrue in the meantime. The check runs only when the call supplied
cpp_optionsand the executable could be queried, so ordinary reuse stays quiet.This wires up
exe_info_reflects_cpp_options(), which existed and was tested buthad no caller. Note for whoever picks up the rebuild work: it compares
lower-case option names while
model_compile_info()reports upper-caseones, so feeding it that parser's output finds no overlap and always reports
agreement. The names are aligned before comparing.
Pre-existing coherence defects found while tightening the boundary
None of these are regressions from this PR; they are cases where the object could
end up describing a program its executable was not built from.
at the C++ stage left the old executable paired with model-method code generated
from the new source, and that environment is handed to every fit — so
fit$init_model_methods()would compilelog_prob()from a program the drawsdid not come from. Both are now staged in locals and committed with everything
else.
file.remove(exe)followed byfile.copy(tmp_exe, exe)discarded both return values, so a copy failing after asuccessful remove left the model with no executable and no error. Replacement
now stages the new executable beside the destination, moves the old one aside,
renames into place, and rolls back on failure. Every filesystem call is wrapped
in
suppressWarnings()and checked by value, becausefile.rename()warns onfailure and under
options(warn = 2)would otherwise throw before the rollbackcould run. A backup that cannot be cleaned up afterwards is returned rather
than signalled, and warned about only once the optional exposure work is done —
signalling earlier would unwind before the state describing the newly installed
executable was recorded.
$compile()erased$cpp_options(). The recorded options werereplaced with whatever that call supplied — usually nothing. Erasing
stan_threadsmakesassert_valid_threads()warn and dropthreads, so athreaded executable silently ran single-threaded. Masked for
cmdstan_model()by the constructor's metadata merge, so it only bit direct
$compile()calls.$compile()assertedexisting_exeunconditionally, so$expose_functions()failed with "not possible with a pre-compiled Stan model"on a model that had compiled itself. The flag now means what it says: we do not
hold the generated C++ for this executable.
$compile()cleared the precompile options, so a later forcedrecompilation lost the
cpp_options,stanc_optionsandinclude_pathssupplied to
cmdstan_model().$compile(dir = ...)pointing at a different current executable adopted itwhile keeping this object's generated C++ and metadata. It now rebuilds there.
Paths are compared canonically, so symlink aliases,
..components and Windowscasing do not cause needless rebuilds.
$compile(dry_run = TRUE)also no longer records$cpp_options()or moves$hpp_file(), which previously pointed at a temporary file the dry run neverwrote. Two existing tests asserted on
$cpp_options()after a dry run and now usea mocked successful compile instead.
One implementation note a reviewer may want: the resolver strips both header
spellings from
cpp_optionsand only$compile()reinserts the selected one, soprecompile_cpp_options_deliberately never carries a header. Storing it therewould mean storing a WSL-safe path, which the next
$compile()would then selectas its
user_header— a host path by design, sincefile.exists()on it breaksunder WSLv1.
user_header_is the single source instead.Testing the follow-up
tests/testthat/helper-mock-cli.Rpreviously returnedstatus = 0withoutproducing the executable
makewas asked for, which is why the uncheckedreplacement was invisible to the test suite. It now creates the artifact when and
only when the mocked compile succeeds. That in turn required moving the mocked
compiles in
test-model-recompile-logic.Ronto temporary copies: they targeted<cmdstan>/examples/bernoulli/bernoulli.stan, so a faithful mock overwrites theCmdStan installation's own example executable with an empty file. That file is not
in the repository and a truncating overwrite leaves no diff at all, so the file
carries a guard test checking its size and mtime before and after.
New tests cover the staged replacement and each of its failure modes (snapshotted,
including the recovery paths the diagnostics name), the
warn = 2behaviour atboth the helper and
$compile()level, header clearing and identity changesthrough all three supply routes, construction-time precedence, and the no-op
path's preservation of
cpp_options()and$expose_functions(). Each wasconfirmed to fail before the corresponding fix.