Skip to content

Refresh model state on recompilation and stop dropping compile-time inputs - #1235

Draft
jgabry wants to merge 21 commits into
masterfrom
bugfix-issue-1228
Draft

Refresh model state on recompilation and stop dropping compile-time inputs#1235
jgabry wants to merge 21 commits into
masterfrom
bugfix-issue-1228

Conversation

@jgabry

@jgabry jgabry commented Jul 27, 2026

Copy link
Copy Markdown
Member

Submission Checklist

  • Run unit tests
  • Declare copyright holder and agree to license (see below)

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:

  • a real recompilation drops previously exposed standalone functions; they must be exposed again with $expose_functions() (or compile_standalone = TRUE)
  • $compile(dry_run = TRUE) no longer writes into self$functions

$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:

  • $compile() falls back to include_paths_, then precompile_include_paths_
  • the stored header is reused when neither the argument nor a cpp_options entry is given, without firing the "specified both via…" warnings (this chain has since been replaced by a shared resolver — see the follow-up section below)
  • cmdstan_model() now stores its user_header argument, which was previously only passed through to $compile() — so with compile = FALSE it was lost entirely and even the first $compile() failed
  • the three precompile_* <- NULL assignments moved inside if (!dry_run); clearing them ran even when nothing was compiled
  • $include_paths() no longer gates on the executable existing, so it stops returning NULL after a dry run or once the exe is removed

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:

  • Source configuration — what the next stanc/make invocation should use
    (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 a
    value is accepted; existence only when compiling, so a header created between
    cmdstan_model(..., compile = FALSE) and $compile() still works. This also
    closes a hole above: a failed $compile(user_header = h) used to leave
    using_user_header_ FALSE, reintroducing the bogus "declared without
    specifying a definition" error.
  • Artifact description — what the current executable is (stan_code_,
    variables_, functions, model_methods_env_, hpp_file_, cpp_options_).
    Committed only after verified replacement. exe_file_ and cmdstan_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.
  • Divergence markeruser_header_dirty_, which is neither: it records that
    configuration 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.

  • A relative header supplied with compile = FALSE was left unresolved, so a
    later $compile() from a different working directory looked for it in the
    wrong 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.
  • There was no way back to no header once one was persisted. $compile() now
    accepts user_header = NULL, which clears a header supplied through any of the
    three routes (user_header, cpp_options$USER_HEADER,
    cpp_options$user_header). Because clearing changes what would be built, it
    forces recompilation rather than taking the up-to-date path — as does changing
    from one header to another. Duplicate spellings in cpp_options are reduced to
    the one actually used, so $cpp_options() no longer reports the ignored
    duplicate after a successful compile.

The same precedence now runs at construction, not just in $compile(): previously
cmdstan_model(f, compile = FALSE, user_header = NULL, cpp_options = list(USER_HEADER = h)) silently ignored the explicit NULL and built with h.
The user-header chain is now a single pure resolver called from both
initialize() and compile(). character(0) is rejected with a message naming
the argument instead of failing later with "invalid 'file' argument".

A quiet lie this surfaced

Supplying cpp_options to a compile that finds the executable up to date records
them without rebuilding anything. So cpp_options = list(stan_threads = TRUE)
over an existing unthreaded executable made $cpp_options() report threading and
printed ", with 2 thread(s) per chain..." — a message cmdstanr emits itself —
while the binary, compiled without STAN_THREADS, ran single-threaded. Results
were correct; the performance the user asked for silently never happened.

$compile() now warns in that case and points at force_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 in
test-model-recompile-logic.R); this only stops the package from asserting
something untrue in the meantime. The check runs only when the call supplied
cpp_options and the executable could be queried, so ordinary reuse stays quiet.

This wires up exe_info_reflects_cpp_options(), which existed and was tested but
had no caller. Note for whoever picks up the rebuild work: it compares
lower-case option names while model_compile_info() reports upper-case
ones, 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.

  • Model-method and HPP state were replaced before the compiler ran. A failure
    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 compile log_prob() from a program the draws
    did not come from. Both are now staged in locals and committed with everything
    else.
  • The executable replacement was unchecked. file.remove(exe) followed by
    file.copy(tmp_exe, exe) discarded both return values, so a copy failing after a
    successful 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, because file.rename() warns on
    failure and under options(warn = 2) would otherwise throw before the rollback
    could 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.
  • An up-to-date $compile() erased $cpp_options(). The recorded options were
    replaced with whatever that call supplied — usually nothing. Erasing
    stan_threads makes assert_valid_threads() warn and drop threads, so a
    threaded executable silently ran single-threaded. Masked for cmdstan_model()
    by the constructor's metadata merge, so it only bit direct $compile() calls.
  • An up-to-date $compile() asserted existing_exe unconditionally, 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.
  • An up-to-date $compile() cleared the precompile options, so a later forced
    recompilation lost the cpp_options, stanc_options and include_paths
    supplied to cmdstan_model().
  • $compile(dir = ...) pointing at a different current executable adopted it
    while keeping this object's generated C++ and metadata. It now rebuilds there.
    Paths are compared canonically, so symlink aliases, .. components and Windows
    casing 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 never
wrote. Two existing tests asserted on $cpp_options() after a dry run and now use
a mocked successful compile instead.

One implementation note a reviewer may want: the resolver strips both header
spellings from cpp_options and only $compile() reinserts the selected one, so
precompile_cpp_options_ deliberately never carries a header. Storing it there
would mean storing a WSL-safe path, which the next $compile() would then select
as its user_header — a host path by design, since file.exists() on it breaks
under WSLv1. user_header_ is the single source instead.

Testing the follow-up

tests/testthat/helper-mock-cli.R previously returned status = 0 without
producing the executable make was asked for, which is why the unchecked
replacement 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.R onto temporary copies: they targeted
<cmdstan>/examples/bernoulli/bernoulli.stan, so a faithful mock overwrites the
CmdStan 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 = 2 behaviour at
both the helper and $compile() level, header clearing and identity changes
through all three supply routes, construction-time precedence, and the no-op
path's preservation of cpp_options() and $expose_functions(). Each was
confirmed to fail before the corresponding fix.

jgabry added 5 commits July 27, 2026 11:14
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.
@jgabry jgabry changed the title Bugfix issue 1228 Refresh model state on recompilation and stop dropping compile-time inputs Jul 27, 2026
@codecov-commenter

codecov-commenter commented Jul 27, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 92.27468% with 18 lines in your changes missing coverage. Please review.
✅ Project coverage is 91.81%. Comparing base (ee423bf) to head (831f206).

Files with missing lines Patch % Lines
R/utils.R 73.52% 18 Missing ⚠️
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.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

jgabry added 7 commits July 27, 2026 17:30
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
jgabry marked this pull request as draft July 28, 2026 01:36
jgabry added 8 commits July 27, 2026 19:38
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants