Skip to content

Keep model state consistent with the executable, and stop dropping compile-time inputs - #1235

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

Keep model state consistent with the executable, and stop dropping compile-time inputs#1235
jgabry wants to merge 35 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 four 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 markersuser_header_dirty_ and include_paths_dirty_, which
    are neither: they record that configuration and artifact have drifted apart.
    Latched rather than assigned, because on a retry after a failed compile the
    configuration resolves back to itself and nothing looks changed.
  • Command-line provenancebuilt_cpp_options_, the cpp_options actually
    passed to make for the current executable. Distinct from cpp_options_,
    which also carries what the binary reports about itself, because only the
    options this object passed would be dropped by a rebuild that omitted them;
    anything else the binary has came from make/local and would be inherited
    again. Written only in the commit block, so a no-op may augment
    cpp_options_ from metadata while leaving this untouched.

State transitions

The rule above, made concrete. exe_file_ and cmdstan_version_ are the
commented exceptions described in the previous section.

Transition Source config Artifact description Dirty markers exe_file_
cmdstan_model(compile = FALSE) stored untouched stay FALSE — first configuration is not a change unset
cmdstan_model(exe_file = ) stored untouched stay FALSE set
No-op, object built this executable updated carried forward unchanged reasserted
No-op, object adopting an executable updated hydrated from <exe> info; existing_exe = TRUE unchanged set
No-op, cpp_options the binary lacks updated not recorded; warning instead unchanged reasserted
No-op, binary reports options never passed updated cpp_options_ augmented from metadata; built_cpp_options_ untouched unchanged reasserted
Changed header / include paths updated — forced to compile — latched TRUE
Changed destination (dir =) updated — forced to compile — unchanged
Dry run updated untouched retained assigned
Failed stanc or C++ compile updated untouched retained untouched
Failed executable install updated untouched retained untouched (errors)
Successful compile updated, precompile_* cleared committed as a block cleared assigned

Two rows carry most of the bugs here. Failed compile previously replaced
$code(), $variables() and the model-method C++ while leaving the old
executable in place (#1228). No-op previously erased $cpp_options() and
marked a self-built model as pre-compiled (#1234).

The up-to-date check reads the mtimes of the Stan program and the user header
only. Files reached by #include are not checked at any depth, and nothing
records which header or include paths produced an existing executable, so a
fresh object cannot verify the provenance of a binary it did not build. Both
limits are now documented under force_recompile.

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.

Worse than a missed optimisation, it also produced a false error: with
stan_threads recorded, a plain $sample() with no threads_per_chain hit
assert_valid_threads()'s "the model executable was built with threading enabled
but 'threads_per_chain' was not set!" — untrue, and inescapable without
recompiling. The two failure modes chained, since the only way past the error was
to set threads_per_chain and land back in the silent lie.

$compile() now warns in that case and does not record the request. What is
already recorded still describes the binary on disk and is carried forward, so a
bare $compile() still cannot erase it. assert_valid_threads() then refuses
threads_per_chain with its existing warning rather than trusting a claim
nothing verified.

The warning is best effort, and which of two routes it takes matters:

  • The object compiled this executable. Then the options passed to make are
    recorded, so the request is compared against those — which catches options the
    binary cannot report (STAN_CPP_OPTIMS is absent from CmdStan 2.39's output;
    arbitrary make variables never appear). The record is not the whole artifact,
    though: options inherited from make/local never reach $compile() and so
    were never recorded. Anything the binary reports that the record does not hold
    is therefore treated as inherited, applied to both sides of the comparison
    since a rebuild would inherit it again, and added to $cpp_options() so that
    threads_per_chain is no longer refused for an executable that does have
    threading. The comparison is symmetric, because cpp_options are one-shot: an
    option the executable has and the request omits would be dropped by a
    recompilation, so that is a difference too. Rather than re-reading the
    cpp_options list — a second implementation of make's semantics, which drifts
    — the comparison canonicalizes the output of
    cpp_options_to_compile_flags(), so what is compared is literally what make
    is handed. Assignments reduce last-wins by lower-cased name, as a makefile
    does; anything that is not an assignment keeps its order relative to the other
    opaque arguments, though not its position among the assignments; and duplicate
    names, vector values that expand into several assignments, and header entries
    are all resolved before the comparison sees them.

    Two shapes that look like omission are not. FALSE reaches make as
    STAN_THREADS=FALSE, and CmdStan enables some options whenever their variable
    is non-empty. NULL reaches make as an empty STAN_THREADS= — and since
    these are command-line assignments they override make/local, so NULL
    disables the option whatever make/local says, while omitting it leaves
    make/local in force. The cpp_options documentation, which offered the two
    as interchangeable, now says so.

  • The executable was adopted from an earlier session. Then the binary's own
    metadata is the only account available, and it covers a handful of STAN_*
    flags. Anything outside that set passes unremarked rather than being reported
    as a mismatch — unverifiable is not the same as wrong, and warning whenever
    provenance is unknown would fire on ordinary reuse. Record what an executable was built with, alongside the executable? #1238 is what fixes this
    properly.

force_recompile = TRUE remains the way to guarantee a supplied option takes
effect.

Rebuilding automatically on a mismatch is the real fix and remains outstanding
(#1019, and the three skip()ped tests in test-model-recompile-logic.R, which
now name that issue). This change is a step toward it rather than a detour:
cpp_options_ now means exactly one thing — what the current executable was
built with — which is the baseline such a check needs. Recording the request
would have defeated it, since the recorded request matches the next identical
request and a rebuild would never fire.

This wires up exe_info_reflects_cpp_options(), which existed and was tested but
had no production caller — so although the function predates this branch, its
behaviour ships here for the first time, and its input handling was corrected as
part of that rather than inherited. It compared lower-case option names against
model_compile_info()'s upper-case output, which is why it had never once fired;
it also read the cpp_options list directly, so an unnamed raw assignment was
invisible to it, duplicate names took the first rather than the last, and a
vector value errored outright. It now reads through the same parse as everything
else and is case-insensitive about metadata names.

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.
  • Changing include_paths did not rebuild. A #include directive resolves
    against the include paths, so two path vectors can build two different programs
    from one Stan file. $compile() replaced the stored paths eagerly but never
    consulted them when deciding whether to rebuild, so $compile(include_paths = b) on a compiled model reported the new paths and the new $variables() while
    still running the binary built from the old ones — initial values were then
    validated against a program that was not running, and the chains failed inside
    CmdStan. Comparison is ordered, since order decides which directory a directive
    resolves from. The first configuration of an object is not a change: treating
    it as one would rebuild an up-to-date executable in every new R session.

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


Second review round

Six items, all addressed. Three notes where the outcome differs from what was
asked, with the evidence behind each.

  • Changed include_paths did not rebuild — fixed, as described above. Not a
    regression: reproduced identically against the PR base, which had the same
    eager assignment and no include clause in the decision.
  • C++ options the binary lacks are no longer recorded — described above. The
    suggested alternative, rebuilding automatically on a mismatch, is deliberately
    not done here. exe_info_reflects_cpp_options() had never executed in
    production before this branch (the case mismatch above meant it always compared
    an empty overlap), and the metadata keys it depends on vary by CmdStan version —
    2.39 reports no STAN_CPP_OPTIMS at all, so a request for it cannot be checked
    against the binary either way. Switching a rebuild onto a comparator with no
    field history, over a key set that changes between releases, risks recompiling
    on every call for anyone whose requested option is reported but not changed by
    the rebuild. Make compile(..., cpp_options()) consistent with makefile options #1019 tracks it.
  • A user header configured over an up-to-date executable still does not
    rebuild
    — documented rather than changed, and pinned by a test across all
    three supply routes. Provenance cannot be established: the binary does not
    report its header, so "rebuild unless provenance is proven" reduces to "always
    rebuild", once per R session, for exactly the users with the most expensive C++
    builds. It would also close only one direction — an executable built with a
    header and adopted by an object configured without one is the same gap
    reversed. Exposure is narrower than it appears: a model that needs a user
    header cannot build without one (make fails on the missing
    user_header.hpp and leaves no executable), so an existing up-to-date binary
    for such a model was built with some header; the remaining case is a
    different header older than the executable, and the mtime check already
    catches every case where it is newer. The durable fix is recording build
    provenance beside the executable, which would also retire the metadata
    guesswork entirely.
  • Regression tests that did not cross the transition they name — the two
    flagged tests ran two dry runs each, and the precompile state they concern is
    cleared inside the commit block, which a dry run never enters. Fixed, but
    "begin with a successful compilation" was not sufficient on its own: options
    handed straight to $compile() are locals that never enter precompile_*, so
    a second test covers the constructor route that actually exercises the
    clearing. Verified by deleting the two clears — it fails on both assertions.
  • The transitive-include limitation — documented under force_recompile,
    with the framing corrected. It is not only nested includes: a directly
    included file is not checked either, since only the top-level program and the
    user header are stat'ed.
  • PR scope — kept as one PR, with the transition matrix above supplied
    instead. Splitting would spread one state machine across four PRs that each
    edit the same compile() decision block and each need a full CI run, and the
    proposed fourth slice already exists as Make compile(..., cpp_options()) consistent with makefile options #1019. The commits are sequenced and
    individually tested, so git bisect works across them.

Also in this round: $format(overwrite_file = TRUE) gained its missing NEWS
entry, and the skipped mismatch tests now name #1019 so they read as tracked
rather than abandoned.

A second pass raised two more, both taken:

  • Options the binary cannot report were still ignored in silence. Detecting a
    mismatch only through executable metadata meant a request for
    stan_cpp_optims — or any arbitrary make variable — was neither applied nor
    mentioned, contradicting what NEWS claimed. An executable the object compiled
    itself is now answered from what was recorded rather than from metadata, which
    catches those exactly. See the two routes described above.
  • The include-path tests did not cross the transition they named. One used a
    model with no #include at all, so it showed make ran but not that the
    program changed; it now resolves a single directive against two directories and
    asserts $variables() moves with it. The other ran two dry runs, which leave
    precompile_include_paths_ in place, so reuse through the compiled state was
    never exercised.

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 93.03136% with 20 lines in your changes missing coverage. Please review.
✅ Project coverage is 91.92%. Comparing base (ee423bf) to head (82c8cdf).
⚠️ Report is 5 commits behind head on master.

Files with missing lines Patch % Lines
R/utils.R 73.52% 18 Missing ⚠️
R/cpp_opts.R 97.01% 2 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master    #1235      +/-   ##
==========================================
+ Coverage   91.73%   91.92%   +0.18%     
==========================================
  Files          15       15              
  Lines        6281     6511     +230     
==========================================
+ Hits         5762     5985     +223     
- Misses        519      526       +7     

☔ 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.
jgabry added 6 commits July 28, 2026 11:03
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.
A $compile() call that found the executable up to date recorded the
cpp_options it was handed, even after detecting that the binary did not
have them. assert_valid_threads() and the OpenCL checks read those back
as fact, so a plain $sample() failed with "the model executable was
built with threading enabled but 'threads_per_chain' was not set" for a
binary compiled without STAN_THREADS -- an error that is false and that
no argument to $sample() can avoid.

Nothing was rebuilt, so what is already recorded still describes the
executable on disk and is carried forward untouched. The caller learns
their request had no effect from the warning added earlier in this
branch rather than from a field that claims it succeeded. Rebuilding on
a mismatch remains the real fix; the skipped tests now name #1019.

This also gives cpp_options_ a single meaning -- what the current
executable was built with -- which is what a future mismatch check needs
as its baseline. Recording the request would have defeated that check:
the recorded request matches the next identical request, so a rebuild
would never fire.
$format(overwrite_file = TRUE) has cleared the cached variables since
the commit that fixed it, but it was the one user-visible change in this
branch without an entry.
A #include directive resolves against the include paths, so two path
vectors can build two different programs from the same Stan file.
$compile() replaced the stored paths eagerly but never consulted them
when deciding whether to rebuild, so $compile(include_paths = ) on an
already-compiled model reported the new paths and, once the cached value
was cleared, the new $variables(), while continuing to run the binary
built from the old ones. Initial values were then validated against a
program that was not running and the chains failed inside CmdStan.

Marked with a latch rather than compared in the decision itself, for the
reason the user header uses one: a failed compile keeps the new paths, so
on the retry they resolve back to themselves and nothing looks changed.
The comparison is ordered, since order decides which directory a
directive resolves from.

The first configuration of an object is not a change. Treating it as one
would rebuild an up-to-date executable in every new R session, which
leaves an executable adopted from an earlier session unproven; that limit
is documented under force_recompile.
"$compile() doesn't reuse cpp and stanc options from the previous
compilation" ran two dry runs. The precompile state those options travel
in is cleared inside the commit block, which a dry run never enters, so
the test passed only because arguments to one call are absent from
another and never exercised the clearing it is named for.

It now compiles for real through the mocked CLI, and asserts the flags
were present on the first build rather than only absent from the second.
That alone still misses the clearing, because options handed straight to
$compile() are locals that never enter the precompile state, so the
constructor route it does govern is covered by a second test. Removing
the two clears fails that test on both assertions.

Both build a temporary copy: a mocked compile installs a real, empty
executable, which against the shared model would replace it.
force_recompile asked whether the model should be rebuilt "even if it
has not been modified" without saying what counts as a modification. Only
the Stan program and the user header are stat'ed, so an edit to a file
reached by #include goes unnoticed at any depth, including one level
down. A fresh object also cannot tell which header or include paths an
existing executable was built with, because nothing records them and the
binary cannot report them, so configuring different ones does not rebuild
it.

Both are long-standing limits rather than new ones, but the escape hatch
is only useful to someone who knows when to reach for it.
jgabry added 2 commits July 29, 2026 10:59
A user header configured on a fresh object whose executable is already up
to date does not rebuild it, and $cpp_options() does not report the
header, because neither the binary nor anything beside it records which
header produced it. That is a deliberate choice rather than an oversight:
the alternative is recompiling in every new R session for anyone using a
user header, which is the population with the most expensive builds.

Covers all three supply routes, and asserts that using_user_header_ still
holds, since source configuration and the description of the artifact are
separate axes and only the latter is unprovable here.
The three kinds of state this function juggles are explained where each
is assigned, but the rule they are instances of was never written down,
so the reasoning had to be reconstructed from the individual comments.
@jgabry jgabry changed the title Refresh model state on recompilation and stop dropping compile-time inputs Keep model state consistent with the executable, and stop dropping compile-time inputs Jul 29, 2026
jgabry added 7 commits July 29, 2026 12:14
exe_info_reflects_cpp_options() treats "no overlapping metadata keys" as
agreement, and the binary reports only a handful of STAN_* flags. So an
explicit request for an option outside that set -- stan_cpp_optims, which
CmdStan 2.39 omits, or any arbitrary make variable -- was neither applied
nor mentioned, despite NEWS promising a warning.

When the object compiled the executable it holds the generated C++ for
it, and what make was run with is recorded exactly; no metadata query can
improve on that, so the request is compared against the record instead.
The comparison is symmetric, because cpp_options are one-shot: an option
the executable has and the request omits would be dropped by a
recompilation. Names are compared case-insensitively, values as strings,
NULL and FALSE count as omission, and header entries are excluded because
header identity forces a rebuild on its own.

An adopted executable keeps the metadata comparison, and options it
cannot speak to stay unremarked rather than being reported as a mismatch:
unverifiable is not wrong, and warning whenever provenance is unknown
would fire on ordinary reuse. #1238 is the fix for that.

Also stops querying the binary when the answer is already recorded, and
corrects NEWS, which claimed more than the check delivers.

Two include-path tests are strengthened here as well, since they share a
file with the tests above. "changing include_paths forces recompilation"
used a model with no #include at all, so it showed that make ran but not
that the program changed; it now resolves one directive against two
directories and asserts $variables() moves with it. "$compile() reuses
include paths from the previous compilation" ran two dry runs, which
leave precompile_include_paths_ in place, so reuse through the compiled
state was never exercised; it now compiles first and checks the path
still reaches stanc.
normalized_cpp_options() was written from the shape of the R list rather
than from cpp_options_to_compile_flags(), which is what decides whether
two builds differ. Three shapes were mishandled.

FALSE was treated as omission, but it reaches make as
STAN_CPP_OPTIMS=FALSE, and CmdStan enables some options whenever their
make variable is non-empty -- as the cpp_options documentation says of
stan_threads. So requesting FALSE against an executable built without it
would change the executable, and went unmentioned. Only NULL is omission.
FALSE keeps its literal value rather than being folded into TRUE for
known boolean flags: that needs no list of such flags to maintain, and a
TRUE to FALSE request still warns, which suits a user who believes they
are turning something off when the makefile disagrees.

Unnamed entries are raw make arguments and were dropped entirely, so a
model configured only with list("STAN_THREADS=TRUE") normalized to
nothing. Duplicate names took the first occurrence, because subsetting by
name repeatedly returns the first, while every duplicate reaches make and
a makefile takes the last.

Also compares the include-path reuse test against the arguments stanc is
handed rather than the stored path: under WSL the model holds a Windows
host path while include_paths_stanc3_args() converts the argument to
/mnt/<drive>/..., so that assertion would have failed there.
The previous fix mirrored cpp_options_to_compile_flags() instead of
calling it, which is still a second implementation of make's semantics
and drifted from the first in three ways. Sorting named and raw entries
separately lost their relative order, so list("A=1", "A=2") and
list("A=2", "A=1") compared equal despite make ending on different
values; the same held across the raw/named boundary. A vector value
expands to one assignment per element, which the mirror collapsed into a
single comma-joined value.

normalized_cpp_options() now canonicalizes the converter's output.
Assignments reduce last-wins by lower-cased name, as a makefile does, and
anything that is not an assignment keeps its position so a -DFOO is not
read as a variable named -DFOO. Duplicate names, vector values, and NULLs
are all already resolved by the time the flags exist, so there is nothing
left to reinterpret.

That also corrects NULL, which is not omission: it reaches make as an
empty STAN_THREADS=. Since these are command-line assignments they
override make/local, and CmdStan tests them with ifdef, which is a
non-empty test -- so NULL disables the option regardless of make/local
while omitting it leaves make/local in force.
The two were offered as interchangeable ways to leave threading
disabled. They are not: cpp_options reach make as command-line
assignments, which override make/local, so NULL passes an empty
STAN_THREADS= and disables threading whatever make/local says, while
omitting the option leaves make/local's value in force. They coincide
only when make/local is silent about that variable.

The distinction decides whether the up-to-date check treats NULL as a
request that would change the executable, so the documentation should
not imply the opposite.
NULL expands to an empty NAME= rather than to nothing, which the comment
still described the old way. And sorting the assignments moves the opaque
arguments after them, so only their order relative to each other is
preserved, not their original positions.
The self-built comparison treated the recorded options as the whole
artifact, but the record only holds what was passed to make. Options
inherited from make/local never reach $compile(), so a model built with
make/local's STAN_THREADS=true and then handed
cpp_options = list(stan_threads = TRUE) was told its executable lacked
threading. That was a regression from routing around the binary's own
metadata, which had reported the truth.

The binary is now consulted on this route too, and what it reports but
the record never held is taken to have come from make/local. Such options
are applied to both sides of the comparison, because a rebuild would
inherit them again, and merged into cpp_options_ so that later validation
learns them -- suppressing the warning while leaving $cpp_options()
ignorant would still have assert_valid_threads() refuse
threads_per_chain for an executable that does have threading.

Distinguishing inherited from explicit needs the options actually passed
to make, which cpp_options_ can no longer supply once metadata is merged
into it, so built_cpp_options_ records them. Without that split the merge
defeats itself: a metadata-derived STAN_THREADS is indistinguishable from
an explicit one on the next call, and a request that never mentioned
threading reads as a change.

The warning no longer says how the executable was built, since options
inherited from make/local are invisible unless the binary reports them.
What is known is that the two descriptions disagree.
Two places still interpreted the R list rather than what make is given,
and both got it wrong in the same ways. The inheritance filter read
names(built_cpp_options_), so an unnamed raw "STAN_THREADS=TRUE" was
invisible to it: with the binary reporting threading, an explicit raw
assignment was mistaken for a make/local contribution and omitting it
raised no warning. exe_info_reflects_cpp_options() read its own names and
values, so a raw assignment was ignored, duplicates took the first rather
than the last, and a vector value errored outright.

parsed_cpp_options() now does the parsing for all of them, from the
converter's output, and the adopted route compares only the assignments
the binary reports: empty means disabled, any non-empty value means
enabled, matching the ifdef test CmdStan actually uses. Unreportable
assignments stay ignored, so the adopted route's asymmetry is unchanged.

None of this is established behaviour being altered.
exe_info_reflects_cpp_options() had no production caller before this
branch, so these semantics ship for the first time here either way.

The comparator is now case-insensitive about metadata names, which
retires the alignment the call site was doing. All six existing
assertions hold unchanged: exe_info_style_cpp_options() already treated
non-empty as enabled for the five names it knows, and its bugs were in
shapes those tests never used. It is left alone, being dead at the branch
point rather than orphaned here.
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