Repository navigation
v1.0 compilation state: implementation order #1258
Description
v1.0 compilation state: implementation order
An issue to track progress on the design in #1254 (dev-notes/compilation-state.md). The rest of this is written by Claude based on my recommendations.
#1254 §9 explains why the order is what it is and what breaks under the orderings that were rejected. It does not restate the order; this issue owns it. If the order changes, edit here; if the reason changes, edit there.
The release order: stage 4 → stage 5 → NEWS reconciliation → Air's format (optional) → release candidate → 1.0. The candidate ships when everything is ready rather than at the earliest defensible point, so nothing has to be adjudicated as safe-before or safe-after the tag. Air is the one item we may decide not to do at all, and it runs last before the tag rather than after it: a candidate that is not the source we ship is not a candidate. Optionality decides whether Air runs, not when, and that decision is taken when the NEWS reconciliation lands (#1254 §9).
Every stage that changes a public contract downstream has to branch on bumps the dev version in its own pull request. Stages 1, 4 and 5 do; 2, 3 and 3b do not. That gives brms a packageVersion("cmdstanr") boundary from the v1.0 branch the day a break lands there, instead of waiting for the candidate tag. Stages land on that branch, one pull request each, and the branch merges to master at the release, not at the candidate (#1254, "How the stages are executed"): too many people install from GitHub master to let the breaks reach it one stage at a time, and the README's first install path is r-universe, which builds master, so a merge at the candidate would hand 1.0 to every CRAN brms user before a guarded brms exists. Bumping in a follow-up commit is worse than not bumping: a guard written against the new number then takes the old branch and calls a method that has already gone.
Closing issues. An issue is closed by hand when its pull request merges into v1.0, since GitHub only closes on merges to master, with a comment saying the fix is on v1.0 and will be in 1.0.0. This issue and #1059 stay open until master.
Two things this rule is easy to get wrong. Guards name a stage, not a number chosen now: brms needs the standalone family, which is stage 4, so its boundary is the stage 4 dev version, whatever that pull request assigns. From 0.9.0.9002, stage 1's bump is .9003, so a guard written against .9003 today would move brms to compile_stan_file() three stages before it exists. The downstream pull requests below carry the real numbers. And the trigger is a contract, not observability: stage 3 creates a sidecar beside every executable and can print the untracked-dependency note, both observable, and neither is something a downstream package could write an if against. instantiate carries the record into the package library without needing to do anything, and this is an assertion rather than an open question. Its install.libs.R copies the sources into R_PACKAGE_DIR/bin/stan and compiles there, so the record is written beside the executable and R's move of the staged tree carries both. That is the mechanism #1254 §9, "Its runtime model stays executable-only", already measures with a real R CMD INSTALL, and the reason #1254 §9, "cmdstanr cannot repair an install-time-built model", predicts a 00LOCK-…/00new/… build path. The staged-install test under the downstream pull requests below is what holds it.
One pull request per stage, green and revertable on its own. Only one compiling task runs at a time: make/local and the precompiled headers live in the CmdStan installation, not in the checkout, so separate checkouts do not separate them. Stages 2 and 3b compile nothing, so they are the two that can be worked alongside something else.
Stage 0: landing in #1235
-
$variables()and$code()return stale results after the Stan file is edited and recompiled #1228 - compile() drops the include paths and user header a model needs, so it cannot be recompiled #1234
- in #1235 cmdstan_model() queries the executable twice when it is already up to date #1236
Stage 1: Make-option correctness (merged to v1.0 in #1262, 7dc846e)
- cpp_options = list(stan_threads = FALSE) enables threading instead of disabling it #1251: logical
FALSEmust disable - Unnamed raw cpp_options assignments reach make but are invisible to everything that keys on names #1250: casing and raw assignments
- Include paths for Make are hand-quoted rather than properly escaped #1230: include path escaping
- Quoted values in make/local STANCFLAGS are split on whitespace and break direct stanc calls #1232: quoted values in
make/localSTANCFLAGS. Its include-path example is now a rejection test; the quoting fix gets its own fixture,--filename-in-msg='/my dir/model.stan'inmake/localreaching direct stanc as one argument, which stage 4 reuses to assert the drop under the flag-precedence rule leaves no stray element - Named
cpp_optionsentries are normalized to theirmakespelling (uppercase) once, on entry to the build call, after the name-shape check and before the reserved-name checks.cpp_options_to_compile_flags()(R/cpp_opts.R:131) uppercases them on the way out, solist(USER_HEADER = h),list(user_header = h)andlist(User_Header = h)are one variable tomakeand three values to R, and the codebase reconciles that three times in two directions today:toupper()outbound,tolower()inparsed_cpp_options()(:100) inbound, andtolower()again in the dormantvalidate_cpp_options()(:165). Canonicalizing on entry means validation, comparison, the record and$cpp_options()all see one spelling, and the reserved-variable rejections below match literals instead of folding case themselves. It also retiresparsed_cpp_options()'s exclusion list (:101), for a different reason on each entry:user_headercannot reach a supplied list because the named spelling is rejected below, while a suppliedSTAN_VERSIONis an ordinary Make variable that CmdStan itself never reads (the name is one cmdstanr synthesizes atR/cpp_opts.R:68from the threestan_version_*fields<exe> infoprints, andCMDSTAN_VERSIONis CmdStan's own version variable), but a user'smake/localcan read$(STAN_VERSION)off the make command line and changeCXXFLAGSwith it, so it is recorded and compared likeFOO, and excluding it would drop a supplied entry that can change the artifact.exe_info_reflects_cpp_options()(R/cpp_opts.R:327) is re-keyed in this item and deleted in stage 4 (Design note: v1.0 compilation state and C++ options #1254 §3, "Its key fold is changed with this rule, not after it"): it matches the parser's names againsttolower(names(exe_info)), so the moment the parser stops folding case that intersection is empty for every option and the check silently returnsTRUE. Its caller is the reuse branch of$compile()(R/model.R:777), reached by every fresh session that constructs a source-backed model whose executable exists, so it cannot go before the engine replaces it: twelve assertions acrosstest-model-recompile-logic.R,test-model-generate_quantities.Randtest-cpp_opts.Rexpect its warning. Drop the fold so both sides carry themakespelling. The deletion, with its branch, its tests (test-cpp_opts.R's "exe_info cpp_options comparison works" and "exe_info comparison reads cpp_options the way make does") andexe_info_style_cpp_options()(R/cpp_opts.R:312), whose only caller is the first of those tests, is a stage 4 item under Rebuild when the recorded build no longer matches what was asked for #1255. Its name is wrong anyway: it listsstan_cpp_optimsamong the flags reported in exe info, andwrite_stan_flags.hppreports four, not five. This item also changes what$cpp_options()returns. See the NEWS entry under Before the release candidate.list(stan_threads = TRUE)keeps working.stanc_optionsare left alone: stanc is case-sensitive and rejects--Warn-Pedanticwith a better message than ours - Every channel rejection below matches on where the option name occurs, not on enumerated values, because
stanc_options_to_args()(R/model.R:2598) puts the flag name in a different slot per entry shape. Reject a named entry whose name is the flag whatever its value, and an unnamed entry whose value is the flag or begins with the flag followed by=. A named entry's name may not contain=(Design note: v1.0 compilation state and C++ options #1254 §3, "A named entry's name may not contain="):list("include-paths=/b" = TRUE)has a name that is notinclude-pathsand emits--include-paths=/b, so it needs a test of its own, an error naminginclude_paths. The check goes inassert_valid_stanc_options()(R/model.R:2562) beside the leading-hyphen check.warn-pedanticalone has six spellings the converter treats differently: unnamed, namedTRUE, namedFALSE, namedNA, namedNULL(which emits--warn-pedantic=) and named"yes". Two of them emit nothing, so a check keyed on the arguments that reach stanc passes them.make/localis excluded: it is text in CmdStan's own file rather than a list entry, and keeps the substring test noted below -
include_pathsbecomes the only channel into stanc's search path.--include-pathssupplied throughstanc_options(matched on occurrence, per the rule above), throughmake/local'sSTANCFLAGS, or throughSTANCFLAGSincpp_optionsreaches the build (R/model.R:837,:839, and a make command-line assignment that cmdstanr'sSTANCFLAGS +=appends to rather than replaces) but not thestanc --infocall re-resolution is built on (:2668), so it resolves at build time and nowhere else: a model built that way compiles and then fails on$sample(), which calls$variables()unconditionally (:1410). A live bug in released cmdstanr, never filed. All three are rejected with an error naming the dedicated argument, andSTANCFLAGSincpp_optionsis rejected outright, sincestanc_optionsis the channel for stanc flags and a raw make-variable passthrough only duplicates it; in themake/localcase detection is a substring test on the value Make resolves, not a parse of the file, matching--include-pathsor an element beginning with-I, the short spelling stanc added in 2.38, and it runs at build time only, recording nothing (Design note: v1.0 compilation state and C++ options #1254 §6, "TheSTANCFLAGScheck reads what Make resolved, not whatmake/localsays, and runs at build time only"). The two rejections differ in scope on purpose:cpp_optionsis a cmdstanr argument so the wholeSTANCFLAGSvariable goes, whilemake/localis CmdStan's own config file (make/local.example:20suggestsSTANCFLAGS+= --warn-pedantic) so only the include-path flag is refused there. Put thecpp_optionscheck inassert_valid_cpp_options()(Unnamed raw cpp_options assignments reach make but are invisible to everything that keys on names #1250), whichcmdstan_make_local()does not call (R/install.R:324-338), so writingSTANCFLAGSintomake/localthrough the supported function keeps working. Tests for themake/localarm:STANCFLAGS += --include-paths=/b,STANCFLAGS += -I /bandSTANCFLAGS += -I/b, each rejected with the error naminginclude_paths. Breaking, so it needs a NEWS entry. Must land before stage 3b, whose decision table encodes the rule this makes sound - A flag the call emits, supplied or injected, wins over the same flag in
make/local'sSTANCFLAGS: drop themake/localoccurrence from the resolved vector before both stanc invocations, matched as the flag itself or the flag followed by=; when the match is the bare flag, the next element goes with it if it does not begin with a hyphen, since that is the flag's value given separately and stanc accepts--filename-in-msg published-model.stanas two arguments; one hyphen, not two, because-fno-soais a stanc option and must not be consumed as the value of a--warn-pedanticbefore it (Design note: v1.0 compilation state and C++ options #1254 §6, "A flag the call emits wins over the same flag inmake/local'sSTANCFLAGS"). stanc's handling of a repeat varies by version, 2.37 refuses every repeat and 2.38 and 2.39 refuse valued ones, sopedantic = TRUEagainstmake/local.example's--warn-pedanticline andlist("O1")against amake/local--O1fail the build today on 2.37. Not the include-path rejection, which stays. Tests, each with the flag inmake/localand asserting stanc sees it once:pedantic = TRUEwith--warn-pedantic;list("O1")with--O1; and at stage 4, the injected--filename-in-msgagainst amake/localvalue in each form,--filename-in-msg=published.stanand--filename-in-msg published.stan, the call's winning and stanc seeing no straypublished.stanelement; andpedantic = TRUEagainst--warn-pedantic -fno-soa, with-fno-soastill reaching stanc - The
user_headerargument becomes the only channel for the user header.cpp_options[["USER_HEADER"]]andcpp_options[["user_header"]]are rejected with an error naming it. This deletes most ofresolve_user_header()(R/cpp_opts.R:189-245), which exists to reconcile the three spellings (both casings tracked positionally for make's last-wins rule, a four-level precedence chain, two conflict warnings) and whosepreviousparameter Remove deferred compilation and $compile(); add standalone file operations #1256 removes along with deferred compilation. Itssuppliedflag goes with them (Design note: v1.0 compilation state and C++ options #1254 §7, "ExplicitNULLmeans omission for all six, so one sentinel covers them"): the flag exists to give the argument precedence over the two spellings and otherwise to fall back toprevious, so with both goneuser_header = NULLand an omitteduser_headerare the same request.initialize()'s"user_header" %in% names(args)test (R/model.R:279) goes with it, and$compile()'smissing()check (:634) goes with$compile(). Add$user_header()so the dedicated argument has a dedicated accessor; without it$cpp_options()[["USER_HEADER"]]is the only way to read the header back. 14 test call sites use thecpp_optionsspelling. Breaking, needs a NEWS entry - Reject
allow-undefinedinstanc_options, matched on occurrence, with the same error as the header channel above, since it is the flaguser_headerimplies and not an independent setting. Pair it with the rule below so the escape hatch and the thing it escaped are not removed in one step - Reject
use-openclinstanc_options, matched on occurrence, namingcpp_options = list(stan_opencl = TRUE). It is the flagstan_openclimplies (R/model.R:676-678), and supplying it alone never produces an OpenCL-enabled executable. It produces one of two other things, chosen by the model. stanc emitsmatrix_clmembers only where a GLM-family function takes data it can move to the device, and those types exist only whenSTAN_OPENCLis defined, so such a model fails with six C++ template errors instead of one sentence. A model without such a call (bernoulli.stan, measured on 2.39) emits C++ identical but for the embeddedstancflagsstring, builds, and reportsSTAN_OPENCL=false. That is the worse outcome of the two, since nothing tells the caller their request did nothing - Reject
nameinstanc_options, matched on occurrence, with an error saying the model name comes from the file name. The one rejection with no argument to redirect to, sinceR/model.R:273takes the name from the file's basename and nothing else writes it (Design note: v1.0 compilation state and C++ options #1254 §3, "A flag cmdstanr derives from another argument is not separately settable"). It is half-wired today: a suppliednamereaches stanc but never touchesprivate$model_name_, sostanc_options = list(name = "foo")leaves$model_name()answeringbernoulliwhile every CSV the binary writes stampsfoo. Letting the option writemodel_name_would reconcile that and is the alternative considered. It is rejected because it earns nothing, since a model compiled from a string is named throughwrite_stan_file(basename =)(R/file.R:61) and two models that need telling apart in a CSV header need distinct file names anyway. This makes the injections atR/model.R:834and:1138unconditional; theoption_name != "name"quoting exception instanc_options_to_args()(:2611) stays, since it guards cmdstanr's own injected flag. One test uses the channel,test-model-compile.R:370-378, and asserts only that the flag is forwarded. Checked: brms and instantiate never mentionstanc_optionsat all, and rethinking'scstan()andulam()default it tolist("O1")and forward the caller's list, so a rethinking user reaches this the way they reach the other rejections. Breaking, covered by the consolidated NEWS entry - Delete
validate_cpp_options()(R/cpp_opts.R:151) and its tests (test-cpp_opts.R:24-37). It is dead code, called from nowhere inR/, but the reason to remove rather than adopt it is cpp_options = list(stan_threads = FALSE) enables threading instead of disabling it #1251: its one substantive behaviour warns that a logicalFALSEwill turn an option on, which cpp_options = list(stan_threads = FALSE) enables threading instead of disabling it #1251 reverses, so keeping it would document the opposite of v1.0's semantics. The new checks live inassert_valid_cpp_options()(Unnamed raw cpp_options assignments reach make but are invisible to everything that keys on names #1250), pairing withassert_valid_stanc_options()(R/model.R:2562) - With no
stan_file, an explicitly supplied argument that can only be honoured by building or by reading the source is an error (Design note: v1.0 compilation state and C++ options #1254 §7):cpp_options,stanc_options,include_paths,user_header,force_recompile,pedantic.cpp_options,stanc_options,user_headerandforce_recompilecannot configure an artifact that will not be rebuilt, and a valid record is there to be inspected rather than overridden.include_pathsandpedanticfail on the source instead, and that reason belongs in their messages:include_pathsconfigures source resolution, needed by every stanc invocation whether or not anything compiles, whilepedanticasks for a stanc run over a program that is not there, so the guarantee that it reports on every call cannot be kept quietly. Check whether the argument was supplied, not what it resolves to: today's default isgetOption("cmdstanr_force_recompile")(R/model.R:621), so a check written asisTRUE(force_recompile)would error for every adoption performed by anyone with that option set, including everyinstantiatefit from inside a package the user never chose to look at. That default leaves the signature (Design note: v1.0 compilation state and C++ options #1254 §7, "No signature resolves thecmdstanr_force_recompileoption"), and that is three functions rather than two, sincecmdstanr_example()resolves it in its own signature atR/example.R:62and hands the answer tocmdstan_model(). Notmissing(): a wrapper's ownNULLdefault already makes itFALSEin the shared implementation, and the shared implementation is where the rebuild reason has to tell an explicitforce_recompile = TRUEfrom an option set in.Rprofilemonths ago. Document on the option's help page that it has no effect on executable-only models, so the advice arrives as documentation rather than as a runtime failure in somebody else's code. Breaking, needs a NEWS entry. Stage 1 reads the captured...by exact name, so an abbreviation such asforce = TRUEorcpp_opt = list()besideexe_filepasses the check and partial-matches on the build path (found in review of v1.0 stage1: make-option correctness #1262). Left alone on purpose: removing$compile()makes these arguments the constructor's own formals, which closes the hole with no matching code, and the abbreviated spellings join that item's test matrix - Reject
warn-pedanticinstanc_options, matched on occurrence, with an error namingpedantic = TRUE. Two channels for one setting, and here they would differ in kind rather than in spelling:pedantic = TRUEis injected and not compared, so Design note: v1.0 compilation state and C++ options #1254 §8 reruns the check on an up-to-date model, while the same flag throughstanc_optionsis supplied and compared, so it warns only when a build happens. The namedFALSEhas a reason of its own on top of that: it emits nothing today whilepedantic = TRUEstill injects, so it reads as a way to switch pedantic off and is not one
Stage 2: schema and helper tests (merged to v1.0 in #1264, faa40ab)
- Record what an executable was built with, alongside the executable? #1238: the record format, parser, writer and comparison helpers
- Replace the hand-enumerated
tests/testthat/resources/stan/.gitignorewith patterns, before anything writes a record beside a test model. It currently lists executable basenames (/bernoulli,/schools, …) so it will not match a record, and the leading dot hides the file fromlsbut not from git, sogit add -Acommits it silently. This is the trap Design note: v1.0 compilation state and C++ options #1254 §4, "This repository needs the patterns too, before Stage 3 writes anything", describes, arriving in our own repository first - Validate every required field before accepting a record, in the parser, not in each caller (Design note: v1.0 compilation state and C++ options #1254 §4, "What the reader cannot use, it rejects as unreadable rather than working with"). The required set belongs to the record's own
format_versionrather than to this cmdstanr: a validator that hardcodes one list turns every older record unreadable the first time a field is added, which is a mass rebuild by the back door (Design note: v1.0 compilation state and C++ options #1254 §6, "Unreadable means the record could not be accepted as a record at all"). Only one format version is live, so there is nothing to fixture here yet and this is a constraint on the validator's shape rather than a test. Checking only the fields the caller at hand happens to need is what leaves one record adoptable bycmdstan_model()in stage 4 and unavailable tostan_build_info()in stage 5, and stage 4 already consumes records, so this cannot wait for the stage that renders them. A record failing any check is unreadable whole: no part of it used, no part reported, including aformat_versionthat parsed. Three fixtures, and the two after the first are the ones an implementation guided by it will get wrong: invalid JSON; a record whoseformat_versionis present and supported but which carries one required field of the wrong type; and a record whoseformat_versionis one this cmdstanr does not read, carrying a body that is invalid under the current schema. Assert that none of the three reaches a comparison, that the first two yield noformat_version, and that the third isunsupported_formatand reports its version. The third fixture is what pins the order of the two steps (Design note: v1.0 compilation state and C++ options #1254 §4, "The version is checked first, and on its own"): an implementation that validates the fields before looking at the version reports every unsupported record as unreadable, which withholds the number Design note: v1.0 compilation state and C++ options #1254 §4's recompile message prints and leavesunsupported_formatunreachable for any record that differs from the current schema - Tri-state round-trip tests, distinct from the helper tests above because they check the format rather than the helpers. Design note: v1.0 compilation state and C++ options #1254 §1 encodes
reported_featuresby presence (a key is written only when the state is known) because the obviousNA-for-unknown encoding does not survive:jsonlitewritesNAasnulland reads it back asNULL, so the R type is gone after one trip through a file andis.na()returnslogical(0), which errors in anif. The two states stay recoverable throughnames(), but not through the access anyone writes:x[["k"]]isNULLeither way and!isTRUE(x)isTRUEeither way, so unknown and disabled collapse. Assert that known-enabled, known-disabled and unknown survive a write/read cycle as three distinguishable outcomes, and that nonullever appears in a written record. Assert the validator's side of it too, since the round trip passes whatever the validator does: a record whosereported_featuresomits a flag is readable, not rejected, because requiring today's four would make every record from a CmdStan that stopped reporting one unreadable, rebuilding every model it built that has a source and stripping the provenance from every one that does not (Design note: v1.0 compilation state and C++ options #1254 §4, "reported_featuresis checked for shape and never for membership").STAN_CPP_OPTIMSmoving is the precedent
Behaviour-free: nothing writes a record beside a user's program until stage 3. The open questions here are all now answered in #1254. The record is a hidden JSON file, .<exe>.cmdstanr.json, written beside the executable. Dependencies are identified by content, with each one's build-time path stored as built_from for provenance and not compared, so moving a project does not rebuild it. The user header's path is compared as well as its content, as one instance of a general rule: directories supplied to cmdstanr for C++ include resolution are compared as spellings, since the C++ closure beneath them cannot be enumerated, the -I flags in cpp_options being the rule's other instance. Includes are compared as an ordered sequence rather than a set. And the record's lifecycle follows the executable's, so whatever ignores the binary ignores the record.
Stage 3: transactional record writing (merged to v1.0 in #1265, 6a5572a)
- Record what an executable was built with, alongside the executable? #1238: staged install of executable and record as a pair, with rollback
- The interleaved-writes case as a test of the pair verification (Design note: v1.0 compilation state and C++ options #1254 §4, "The record must contain a hash of the executable it describes"). Two builds race to one destination: A installs its executable, B installs its executable and then its record, and A installs its record last. What is on disk is executable B beside record A, and atomic replacement of either file alone cannot see it. No real concurrency is needed: write the four files by hand in that order and run the verification step the transaction ends with. It must fail on the hash, and a read of that pair must report a mismatch rather than describe B with A's record. Locking stays out of scope; this pins that the hash catches what ordering cannot
- Enumerate the fields the writer populates, and check each against this stage. This is where records begin, so every field in Design note: v1.0 compilation state and C++ options #1254 §4's table must be computable here: a field whose computation lands later means stage 3 writes a value that is wrong rather than merely absent, and a record that still matches later is never rewritten to correct it. Four of §4's rows fail that check as originally staged, addressed by the two prerequisite items below:
known_untracked_dependencies, plus the three option rows that depend on the split,cpp_options_supplied,stanc_options_suppliedandstanc_options_injected, none of which is computable while injections land in the caller's list.reported_featurespasses and is captured here even though the live behaviour that consumes it is stage 4, which is worth stating so the capture does not look deferred too. The rest,stanc_name,include_paths, thedependenciesfields (the user header among them),artifact,builderandformat_version, are computable today;stanc_nameis the effective name, which the merged list already holds, so it needs the split for ordering rather than for correctness.tbb_diris the one row left over, and it is computable here too, from the call's owncpp_optionsrather than from anything the executable reports, which is why it has its own item below rather than a place in this list - Stop merging the injections into the user's list. Moved forward from stage 4: the record cannot be written correctly without it.
R/model.R:673,:677,:693and:835all write into the samestanc_optionsvariable, so by the time the writer runs the user's entries and cmdstanr's are indistinguishable.cpp_optionsneeds no accumulator, but it does need a deletion, and it is the worse bug::709writes the resolved user header back in under whichever spelling was used, and:941stores that list, so$cpp_options()today reportsUSER_HEADER = "/abs/path/inc/mine.hpp"for a caller who passeduser_header = "inc/mine.hpp"as an argument and never touchedcpp_options. That one does not add an entry beside the caller's, it replaces the caller's value with a resolved,wsl_safe_path()-transformed absolute path, socpp_options_suppliedread off that variable is wrong even with no concept of injection at all.USER_HEADER=still has to reachmake(make/program:41) and does so as a flag built with the others, not as a recordedcpp_optionsentry, since recording it would hold the header's path inrequestas well as independencies, and under WSL not even in the same spelling (Design note: v1.0 compilation state and C++ options #1254 §3, "It reaches it as a flag built with the others").resolved_header$spellingdies with:709, its only consumer. With:709gone,cpp_optionsholds exactly what the caller passed, which is why there is nocpp_options_injectedfield to build and the accumulator work below isstanc_optionsonly. Stage 3 would then have to either put the merged list into_supplied, which silently makes every injection a compared option and makes togglingpedanticrecompile, or reconstruct the split by subtraction, which is the reconstruct-after-the-fact fragility Design note: v1.0 compilation state and C++ options #1254 §4, "Origin is stored, not inferred", rejects by name. Accumulate injections into their own list and merge only when converting to arguments, so_suppliedand_injectedare both values the code already holds. Do not solve it with a snapshot taken before the first injection site; that works until someone adds a fifth one above it. Behaviour-free on its own - Record
request.stanc_name, the--namestanc receives, as a separate compared field as well as its place instanc_options_injected: the injected list records who asked, this records what stanc got, and only the second is compared (Design note: v1.0 compilation state and C++ options #1254 §4). Withnamerejected fromstanc_optionsin stage 1, the derived value is the only one there is. It rides on the item above, sinceR/model.R:835is one of the four sites the accumulator splits, but it is separate because it is compared and the injected list is not. Without it, moving a source, its executable and its record together under a new name changes nothing compared (content hash, artifact hash and builder all match, supplied options are empty on both sides), so the binary is reused while$model_name()and the name compiled into it disagree. That contradiction is visible inside R, not just in the CSV text:R/csv.R:873maps the CSV header ontofit$metadata()$model_name. What the comparison buys is where the CSV boundary falls, not avoiding one: uncompared, the stamp changes on whatever unrelated rebuild comes next andcheck_csv_metadata_matches()(:948-951) rejects the mixture then. The field is namedstanc_namerather thanmodel_nameso that it cannot be read as a second answer tomod$model_name(), which returns the same name without the suffix. Record the value as passed, including the_modelsuffixR/model.R:835appends. Not because it is what CmdStan stamps, which it is not: stanc mangles characters that cannot appear in a C++ identifier, and by hex escape rather than substitution. Measured identical at 2.35, 2.36 and 2.39:my-model_modelcompiles tomy_model_model,my.model_modeltomyx46model_model,my+model_modeltomyx43model_model. Record what is passed, because that is what both sides of the comparison compute the same way, and because reproducing a compiler's mangling in R would drift silently in the direction that does not rebuild. Assert it with amy-model.stanfixture, whose record must hold the rawmy-model_model: an implementation that reads the name back off the built binary, or normalises it on the way in, passes every fixture whose name is already a legal identifier. Two consequences are accepted rather than fixed: a punctuation-only rename rebuilds although the compiled name is unchanged, the artifact differing only in the raw string CmdStan writes onto astancflagsline in each sampler output CSV, which cmdstanr does not parse; and$model_name()still will not match the compiled name for a mangled file, which is true today. The comparison itself belongs to stage 3b's decision table. No NEWS entry of its own, since it is covered by the consolidated rebuild-feature entry below - Document the dependencies cmdstanr does not track #1257: run both detectors, populate
known_untracked_dependencies, and emit the write-time note. Moved forward from stage 4 for the same reason: Design note: v1.0 compilation state and C++ options #1254 §6, "Surface it when the record is written, and throughstan_build_info()", keys the note on writing a record, and writing starts here, so as staged the trigger shipped a stage before the thing it triggers on. Worse, an empty field written because nobody looked is indistinguishable from one where the regex found nothing, which is the exact confusion Design note: v1.0 compilation state and C++ options #1254 §6, "The field isknown_untracked_dependencies, notprovenance_complete", spends a subsection prohibiting. The regexes are the two in Design note: v1.0 compilation state and C++ options #1254 §6, "Provenance we cannot complete", which now include GNU Make'ssincludespelling. It is a fixed keyword, so it costs one alternation and no Make parsing. Test positive and negative detection for both, withsincludeamong the positivemake/localcases, and that the note fires on a successful write and not otherwise. Displaying the field throughstan_build_info()stays in stage 5 - Record
tbb_dir, the absolute TBB directory the call named (Design note: v1.0 compilation state and C++ options #1254 §4, thetbb_dirrow). Read the call'scpp_optionsasmakereceives them, last assignment wins andFALSEis an empty one, and take the first non-empty ofTBB_LIB,TBB_BINand the installation's ownlib/tbb, which is the order the makefile links in. Resolve a relative directory against the installation, wheremakeruns. Not asked ofmake: aTBB_LIBorTBB_BINfrommake/local,~/.config/stan/make.localor the environment moves the linked TBB and not the field, so such a build launches on Windows with the installation's TBB first, as today; the query was tried and dropped because CmdStan'sprint-%echoes through a shell that misspells Windows paths. Launch the model executable with the TBB its build resolved #1261 is the consumer. Tests: a default-layout build recording the installation's ownlib/tbb; a build withcpp_options = list(tbb_lib =)recording that directory and not the default; a relativeTBB_LIBrecorded absolute; and the helper on a repeatedTBB_LIB, aFALSEone withTBB_BINbeside it, and aFALSEone alone - Document the record's lifecycle where users will look for it (Design note: v1.0 compilation state and C++ options #1254 §4, "In practice that means
.gitignore"). The rule is one line: whatever ignores the executable ignores the record, and wherever the executable goes the record goes with it. It needs three concrete cases. Add.*.cmdstanr.jsonbeside whatever already excludes the binary in.gitignore. Do the same in.Rbuildignore, which is the easier miss:R CMD buildexcludes hidden files by a fixed 28-entry list (tools:::.hidden_file_exclusions) that does not include this name and does not match on leading dot, so a package author who compiles in a source tree ships records describing their own machine. And any staging step that copies the executable copies both: CI artifacts, container layers, shared build directories. Home isvignettes/cmdstanr-internals.Rmdin the Compilation section beside "Executable location", which is already where the vignette says where the binary goes. Lands with the writer: before this stage there is no record to ignore, after it every compiled model has one
Stage 3b: the assessment engine, pure and unwired (merged to v1.0 in #1269, 390daa2)
- The rebuild assessment as a pure function with its full decision table, tested against stage 2's fixtures, called by nothing
- A gone builder as a fixture, asserting no rebuild. A record whose
builderpath does not exist, with that same installation selected and every compared field matching, must contribute no rebuild reason (Design note: v1.0 compilation state and C++ options #1254 §6, "A missing builder is reported, and is not itself a rebuild trigger"). What it pins is that the engine never stats the recorded path:builderis compared as a normalized path and a version, and an implementer who adds an existence check turns a reported condition into a rebuild reason. The live call on this fixture does not reach the engine at all: the installation that is gone is also the selected one, so a source-backed model errors before assessment (Design note: v1.0 compilation state and C++ options #1254 §6, "A selected installation that is gone is its own error, checked where it is used"), which stage 4 tests separately. Pair it with the same record under a different, existing selected installation, which must rebuild onbuilderdiffering, which is the ordinary row, and not on the absence. No CmdStan installation is needed for either: the engine takes expected and observed and reads no disk of its own, and a path that does not exist is the cheapest fixture there is - A builder mismatch as a fixture, asserting the reason list and not only the verdict. Re-resolution is skipped exactly when the selected installation differs from
builder, so this fixture'sobservedcarries an unresolved dependency set rather than hashes. It must return rebuild naming the builder and saying nothing about the dependencies (Design note: v1.0 compilation state and C++ options #1254 §6, "It also needs the sources to have been resolved"). The mismatch is itself a trigger, so the verdict is the same whichever way the engine reads that set, which is why a decision table tested only on verdicts stays green against an implementation that reads unresolved as empty and reports every included file as changed. An unresolved set is the difference between a program that includes nothing and one nobody looked at, and this is the fixture that keeps it inside the engine rather than in an undocumented branch in front of it - The rename case as its own fixture pair, because it is the only single-field fixture here that can fail. A record with
stanc_name = "bernoulli_model"against a request identical in every other field (same dependency content hashes, same artifact hash, same builder, empty supplied options on both sides) must return rebuild, with the reason naming the model name (Design note: v1.0 compilation state and C++ options #1254 §4, "--nameis compared as its own row because the build bakes it into the binary and nothing else compared pins it down"). Every other fixture changes a field some other row already covers, so a decision table that never grew astanc_namerow passes all of them green. Add the punctuation-only rename beside it,my-model.stantomy_model.stan, which must also rebuild even though both compile tomy_model_model, so the over-rebuild Design note: v1.0 compilation state and C++ options #1254 §4 accepts is pinned as intended rather than left for someone to normalise away - A replaced executable, which is the one fixture here that tests an argument rather than a row.
expectedcarries artifact hash H1, whileobservedcarries a binary hashing to H2 beside a record whoseartifactis H2: a pair on disk that is self-consistent, with every other compared field matching. It must return rebuild (Design note: v1.0 compilation state and C++ options #1254 §5, "Only the object's own snapshot catches a replaced executable"). An engine written to take the record and the request has nowhere to put H1, so it returns no trigger here while every other fixture in this stage stays green. Nothing is compiled and no installation is needed: any two files with different bytes supply H1 and H2, and the record is fabricated as elsewhere in this stage - The two-headers case as a fixture, asserting rebuild on the path with the content unchanged (Design note: v1.0 compilation state and C++ options #1254 §6, "The user header is therefore matched on normalised path and content"). A record with
dependencies.user_header.built_fromunderexact/and hash H, againstobservedholding the same hash H underapprox/, with every other field matching, must return rebuild naming the user header. Content-only identity passes every other fixture in this stage and fails this one. The live form is the pair of byte-identical headers in Design note: v1.0 compilation state and C++ options #1254 §6 whoseodds_impl.hppdiffer, givingmean(odds)of 0.3261 and -1.0 from the same compared fields; build that only if an end-to-end version is wanted, since the fixture needs no compiler - First, enumerate every cmdstanr argument that becomes a stanc or cpp option. The decision table's central rule is that user-supplied options are compared and injected ones are not, with
stanc_namethe one injected value compared in its own right (Design note: v1.0 compilation state and C++ options #1254 §4, "An injection nothing compares still applies"), so the table cannot be written correctly against an unknown injection set.R/model.R:672-693is where the injections happen, but the audit is the argument-to-option mapping rather than the mutation sites.pedantic = TRUEbecoming--warn-pedanticis the one that was missed entirely through five review rounds, and it was found by accident. This is the defect class tests do not reach: a rule nobody wrote down is not a rule any test enforces. About an hour of reading The audit found every injection compared through its cause or deliberately uncompared and changed no code. - Stanc option comparison needs both directions pinned: the one collapse and the two rebuilds. The comparison is on the argument vector the options emit, in the order given (Design note: v1.0 compilation state and C++ options #1254 §4, "Order is preserved, so a reordered list rebuilds"). Emitting is what collapses spelling, so
list("O1")andlist("O1" = TRUE)must not rebuild each other, which is the assertion that fails if the implementation compares the R list. The two rebuild cases have separate jobs.list("O1")against a record built withlist("O0")must rebuild, and fails against an implementation that never compares stanc options at all.list("O1", "warn-uninitialized")against a record built withlist("warn-uninitialized", "O1")must rebuild too, and that one fails against an implementation that sorts the vector before comparing it. Use flags that survive stage 1's rejections, which rules outwarn-pedantic,allow-undefined,use-opencl,nameandinclude-pathsas fixture material. The order case earns a fixture rather than only a paragraph because it is the one that reads as a bug: measured on 2.39.0,--O1 --O0and--O0 --O1both generate the code plain--O0does and differ only in thestancflagsstring stanc embeds. The fixture pair is identical for a plainer reason,--warn-uninitializedchanging no generated code at all, so either way the rebuild the assertion pins produces the same C++ twice. Design note: v1.0 compilation state and C++ options #1254 §4 prices that and accepts it, because the sort that would avoid it needs the semantics of every stanc option
Depends only on stage 2, and can be worked in parallel with stage 3. It takes two arguments and returns a verdict (#1254 §5, "Two arguments: what this caller expects, and what is on disk"). The record is part of the observed side rather than an argument of its own, and the expected side is what differs between the two callers: at cmdstan_model() it is the options this call supplied, and at a guarded method it is the object's own snapshot, including the artifact hash it was built against. observed carries either the resolved source hashes or a statement that they were not resolved (#1254 §5, "either the source hashes resolved with this call's include paths or a statement that they were not resolved"), which is what keeps the one path that skips re-resolution inside the contract instead of a branch in front of it. It never compiles, never mutates, and nothing invokes it yet, so it is behaviour-free in the same sense stage 2 is and revertable on its own.
It is separated from stage 4 because it is what makes §6 of #1254 checkable. Every rebuild trigger becomes a test with a fixture, and two rules that contradict each other stop being two paragraphs a reader has to hold against each other and become a red suite. That is not a hypothetical: §6 carried "include_paths is not compared as a spelling" and "re-resolution uses the recorded paths" eight lines apart for a full review round, and a test asserting that switching include_paths from v1/ to v2/ rebuilds fails immediately against the second rule. Landing this early moves that check months ahead of stage 4 and shrinks stage 4 to the part that changes behaviour.
This does not weaken the argument below that #1255 and #1256 ship together. That argument is about the engine being live while $compile() is gone; an unwired function changes nothing a user can observe.
Stage 4: the API change and the decision engine, together (merged to v1.0 in #1273, 97030b4)
- Rebuild when the recorded build no longer matches what was asked for #1255: wire the assessment engine in: rebuild when the recorded build no longer matches (closes Make compile(..., cpp_options()) consistent with makefile options #1019). Delete
exe_info_reflects_cpp_options(), its branch in the reuse path (R/model.R:777),exe_info_style_cpp_options()and their tests here, since acpp_optionsmismatch is now a rebuild; the twelve assertions that expect the mismatch warning turn into rebuild assertions (Design note: v1.0 compilation state and C++ options #1254 §3, "That leaves the parser with one caller, because the other is deleted in Stage 4") - §1's request/report split goes live:
$cpp_options()reports only what the caller asked for, and the runtime validators readreported_featuresinstead. Deletemerge_exe_info_cpp_options()(R/cpp_opts.R:78) and every call to it (R/model.R:322,:786, and the post-commit merge Keep model state consistent with the executable, and stop dropping compile-time inputs #1235 added), wire$cpp_options()tocpp_options_supplied, and carryreported_featuresas the tri-state §1 defines (known enabled, known disabled, unknown), with absence never collapsed to disabled. Then moveassert_valid_threads()(R/cpp_opts.R:282) andassert_valid_opencl()(:271) onto it at all twelve sampling entry points: requesting a feature the binary reports disabled or unknown is an error, replacing today's warn-and-discard, which is the silently single-threaded four-hour run; and the converse error for a threading-enabled binary run withoutthreads_per_chain(:297-303) is removed, since an artifact exceeding the request is not a mismatch. Removing that error exposes a leak: the four launch sites (R/run.R:478,:545,:591,:648) writeSTAN_NUM_THREADSinto the R session when threads are supplied, CmdStan reads it as the default whennum_threadsis absent, so an omitted call inherits the previous call's count. Scope the variable to the child throughprocessx::process$new(env = ), whichwsl_compatible_process_new()forwards, set when supplied and absent otherwise, and move theWSLENVexport with it (Design note: v1.0 compilation state and C++ options #1254 §1, "Removing the error exposes a leak, and the count moves to the child process"). Notnum_threads=on the command line: CmdStan refuses to start when it disagrees with a setSTAN_NUM_THREADS. Tests: a consecutive-call test,threads_per_chain = 4then an omitted call, assertingmetadata()$threads_per_chainis 1 on the second; the thirteenSys.getenv("STAN_NUM_THREADS")assertions intest-threads.Rbecome one that the session variable is unchanged by a run. The two halves have a required order and it is easy to get backwards. Validators may move toreported_featuresbefore the merge is deleted, since the merge is then redundant, but deleting the merge first re-breaks Keep model state consistent with the executable, and stop dropping compile-time inputs #1235:STAN_THREADSinherited frommake/localis not incpp_options_supplied, so a threaded binary reads as unthreaded andthreads_per_chainis refused again. The test matrix is that regression, withSTAN_THREADS=trueinmake/localand nothing passed tocpp_options:$cpp_options()empty,reported_featuresreporting threading enabled,threads_per_chain = 4sampling. Assertreported_featuresand the validator, notstan_build_info(). That function is stage 5, and every stage has to be green on its own. It is also the better assertion:stan_build_info()rendersreported_features, so going through it would let a renderer bug fail a test whose subject ismake/localinheritance. Stage 5 tests the rendering against a known state. Plus the tri-state cases: requested-and-disabled errors, requested-and-unknown errors, enabled-and-unrequested proceeds,threads_per_chain = 1on an unthreaded binary proceeds. One of those must be record-backed end to end: a fabricated record with a feature key omitted, adopted from disk, thenthreads_per_chain = 2asserted to error as unknown. Stage 2's round-trip test proves the file is written right and the validator cases prove the validator reads an absent key right, and both stay green if adoption helpfully normalises a missing key toFALSEin between, which is STAN_THREADS in make/local not respected due to capitalisation conflict #765 again. And one must be info-backed, because a record is not unknown's only source: a mocked<exe> inforeturning a valid version withSTAN_THREADSabsent must construct with threading unknown and error onthreads_per_chain = 2the same way. Adoption admits an executable on a valid version rather than on a full flag set (Design note: v1.0 compilation state and C++ options #1254 §1, "Unknown is not expected from a supported binary, but it is possible"), so this path is reachable and nothing else here covers it. An implementation satisfying only the record-backed case can still read a missing key asFALSEon the live path. The fixture is nearly free: stage 4 already builds a fabricated hash-bound record for thebuildertest. Adoption sources the same accessor from the record rather than the call, per the item below. Needs its own NEWS entry for the validator change:threads_per_chainagainst a non-threaded build now errors where it warned, and the built-with-threading-but-not-using-it error is gone - Record-aware adoption (Design note: v1.0 compilation state and C++ options #1254 §7, "Executable plus a valid hash-bound record"). Split adoption out of
initialize()first: it is currently the intersection!is.null(exe_file) && is.null(stan_file), re-derived at each site (R/model.R:302,:320), which is also whyexe_filemeans both "existing binary" and "planned destination" (exe_file_ conflates the installed executable with the planned build destination #1253). With §7 forbidding build configuration here and Remove deferred compilation and $compile(); add standalone file operations #1256 removingcompile, adoption shares nothing with the build path but the argument list, so it becomes its own function and the rest of this item is a property of that function rather than an audit across a constructor. Valid hash-bound record: hydraterequest,reported_featuresandbuilderfrom it and do not launch the executable: the hash proves the binary is the one whose features were recorded, somodel_compile_info()is not called at all. Measured on a 3 MB binary that is ~2 ms against ~24 ms, andinstantiatepays it on every fit rather than once at install (§9).$cpp_options()returns the recordedcpp_options_suppliedand$user_header()the recorded path, which is §1's rule sourced from the record instead of the call.$cmdstan_version()comes frombuilder; that is $cmdstan_version() reports the installed CmdStan, not the version that built the executable #1249, which can land independently first off theSTAN_VERSIONmodel_compile_info()already returns andR/cpp_opts.R:81discards, but adoption is the one path where leaving it unfixed stays wrong forever, since everywhere elsebuilderis compared (§4's recorded/compared table) so a CmdStan change rebuilds and the two converge. Both paths must yield a syntactically valid version, and adoption fails if neither does (Design note: v1.0 compilation state and C++ options #1254 §7, shares cmdstan_model(exe_file = ) surfaces a raw processx error when the executable cannot be run #1246's error). This is the only place a version arrives from an artifact nobody vouched for, so it is the only place the invariant §10 leans on can be established. The record's half is not enforced here: an unparseablebuilderversion fails the reader's field checks like any other malformed field (Design note: v1.0 compilation state and C++ options #1254 §4, "What the reader cannot use, it rejects as unreadable rather than working with"), so such a record is not usable and falls to the branch below. What this item enforces is the fallback:<exe> infomust report complete version fields. Syntactic only, since rejecting a version for being old would defeat §7, whose point is that binaries built by older CmdStan keep working. Without itmodel_compile_info()synthesises".."from three absent fields (R/cpp_opts.R:68), which passes every guardcmdstan_version_compare()has, so construction succeeds and the failure surfaces later inside a version gate as aTRUE/FALSEcomplaint. Test aninforesult missing the version fields and one printing a malformed value. Unusable record (missing, unreadable, hash mismatch, unsupported format, or an unparseablebuilderversion, which Design note: v1.0 compilation state and C++ options #1254 §4 makes a field-check failure like any other): fall back to<exe> info. If it reports a valid version, construct silently with unavailable provenance, together with thereported_featuresthe binary supplies and$cpp_options()empty, never an invented request. If it does not, error. So three outcomes, not two (Design note: v1.0 compilation state and C++ options #1254 §7, "Executable-only models are kept, and adoption has three outcomes"), and only the first two permit fitting and skip the automatic rebuild. Drop the unusedversionparameter frommodel_compile_info()(R/cpp_opts.R:52) while rewriting its callers: three call sites passself$cmdstan_version()into a body that never mentions it, which reads as though the version participates. Tests: the counting mock from Keep model state consistent with the executable, and stop dropping compile-time inputs #1235 extended with a zero-query row for valid-record adoption, which is the only thing separating the design's cost from an implementation that reads the record and spawns the process anyway; a fabricated record withbuilderat 2.35 under a 2.39 session asserting$cmdstan_version()reports 2.35, which needs no second CmdStan installation; missing, corrupt, unsupported-version and hash-mismatched records each falling back correctly; and$cpp_options()empty versus recorded across the two cases. Needs a NEWS entry, and not the one the no-launch rule reads like: launching was already best-effort, sincerun_info_cli()passeserror_on_status = FALSE(R/cpp_opts.R:17), so a binary that cannot run constructs successfully today too. Measured: a six-byte file with the execute bit exits 126 and yields a model whose$cmdstan_version()answers the session's 2.39.0, while the same file without the bit dies on a rawprocessx_execerror, which is cmdstan_model(exe_file = ) surfaces a raw processx error when the executable cannot be run #1246. The two "cannot run" paths disagree today, and this replaces both. What changes is the unusable-record path: adoption now errors, because neither the record nor<exe> infoyields a syntactically valid version, where today construction succeeds andR/model.R:318attributes the session's CmdStan version to a binary cmdstanr never spoke to. Nothing that would have sampled is refused (Design note: v1.0 compilation state and C++ options #1254 §7, "Both paths must yield a syntactically valid version, and adoption fails if neither does"). The no-launch rule itself stays a design statement rather than a change (Design note: v1.0 compilation state and C++ options #1254 §7, "Adoption establishes what the artifact is, not that it runs") - An executable-only model does not rebuild when CmdStan changes (Design note: v1.0 compilation state and C++ options #1254 §9, "The reason is that registering source hands the rebuild decision to the session, and
builderguarantees it fires"). Adopt an executable whose valid record names abuilderother than the selected installation, and assert that construction succeeds, nothing is compiled, and a guarded method runs. Beside it, the same record under a source-backed construction must rebuild onbuilder, which is what shows the difference is the missing source rather than the engine. The fabricated-builderrecord from the$cmdstan_version()test above is the fixture, so no second installation is needed. This is the Monday-to-Wednesday case in Design note: v1.0 compilation state and C++ options #1254 §9: a package installed against one CmdStan,install_cmdstan()the next day, and a fit the day after that must not compile into the package library - Guard the public surface per Design note: v1.0 compilation state and C++ options #1254 §5's classification, and make the classification self-enforcing. Ten members validate and error on any trigger; the rest must not, and the must-nots carry equal weight, since guarding
$format()or$code()would be a regression §5 argues against explicitly. Rather than a static checklist that rots on the next added method, enumerate the live surface withCmdStanModel$public_methodsand$public_fieldsand fail on any member without a classification. Compare against the table's non-removed method rows and assert$compile()'s absence separately. Do not write the assertion against a count: the table is the union of today's surface and 1.0's, so it carries one method row more than 1.0 has members, and a test keyed on the number of rows fails at 1.0 against its own table. Design note: v1.0 compilation state and C++ options #1254 §5 does that arithmetic and is where the numbers belong; copying them here is how the schema-row count went stale last round. Then exercise every member, not one per class: a representative passing says nothing about the other guarded methods, each of which can be classified correctly here and still run a stale executable. Call each guarded method with no other arguments against a stale model, so a method that validates late fails with a missing-argument complaint instead of the staleness error and the matrix checks ordering rather than only presence; none of them get far enough to need MPI, data or an algorithm. The must-nots take the opposite assertion, not the same one. A non-guarded method has no obligation to succeed bare ($save_hpp_file()wants a destination,$expose_functions()wants Rcpp), so requiring the bare call to pass would fail on argument handling while claiming to test staleness. Assert instead that whatever it raises is not the staleness error. The matrix is then exact, and exhaustive by construction rather than by adding up: every guarded method called bare and asserted to raise it; every non-guarded method called bare and asserted not to;$initialize()classified but never invoked (calling it on a live object retargets private state); thefunctionspublic field inspected rather than called, and likewise asserted not to raise it;$compile()asserted absent. A member added later joins the matrix instead of breaking a total. Give the staleness error a condition class in this stage. Most of this matrix is negative assertions, and a negative assertion matched on message text passes forever the moment the message is reworded.$clone()is additionally asserted not to error, and$expose_functions()needs an explicit skip where Rcpp exposure is unavailable, since a silent skip drops a guarded member from the matrix without the enumeration noticing. §5's own justification for listing$initialize()and$clone()is that an unlisted member is indistinguishable from an overlooked one, which is a property a test can hold and a review cannot - The replaced-executable regression, end to end, which stage 3b's fixture cannot reach. Construct object A from a source; rebuild the same executable path through a second object B, so the executable and the record beside it are again a matching, self-consistent pair; then assert that a guarded operation on A errors. Stage 3b proves the engine reads an expected artifact hash, and this proves the object carries one: an implementation that fills
expectedfrom the record found on disk passes stage 3b's whole decision table and fails only here (Design note: v1.0 compilation state and C++ options #1254 §5, "Nothing on disk disagrees, so the disagreement has to be carried in") - The mtime case, end to end (Design note: v1.0 compilation state and C++ options #1254 §4, "The bug this fixes"). Build a model, then overwrite its Stan source with different content and set the file's mtime older than the executable's with
Sys.setFileTime(), which is whattar -x,cp -pand a backup restore leave behind.cmdstan_model()must rebuild naming the Stan program, and a guarded method on the already-constructed object must error. The engine compares hashes and cannot see a timestamp, so this is not a stage 3b fixture; it pins that no caller has fallen back tofile.mtime(), asR/model.R:732-733does today, since such an implementation passes every test whose edit is newer than its binary and fails only here - A failed re-resolution is an error carrying stanc's message, at the constructor and at every guarded method, and nothing rebuilds (Design note: v1.0 compilation state and C++ options #1254 §5, "A re-resolution that fails is an error, not a verdict"). The engine is never called, since the caller has no resolved hashes to hand it. Tests: a constructor and one guarded method, each against a program whose include is missing, both erroring with stanc's message and neither rebuilding
- A guarded method on an executable-only model checks the artifact hash alone, whether the object was adopted with a record or without one (Design note: v1.0 compilation state and C++ options #1254 §7, "A guarded method on an executable-only model checks the artifact hash alone"). No record is read and no source is resolved, so a record deleted after construction changes nothing. Tests: adopt a recordless executable, replace it at the same path, and a guarded method refuses; an unchanged recordless executable still runs
- Remove deferred compilation and $compile(); add standalone file operations #1256: remove deferred compilation and
$compile(), add the standalone family -
_pkgdown.ymlentries for the standalone family (compile_stan_file,format_stan_file,check_syntax_stan_file,variables_stan_file) and removal of any topic the same pull request deletes. The reference index is an explicitcontents:list and pkgdown errors on topics missing from it, so.github/workflows/pkgdown.yamlfails on CI otherwise. Stage 5 carries the same item forstan_build_info. Outcome: no index change was needed, because each standalone function is documented in the topic of its method twin (compile_stan_file()undercmdstan_model, the other three undermodel-method-check_syntax,-formatand-variables), and the index already lists those - Remove
compile_model_methodsandcompile_standalone, in the Remove deferred compilation and $compile(); add standalone file operations #1256 pull request (Design note: v1.0 compilation state and C++ options #1254 §8). Neither is build configuration: they runexpose_stan_functions()andexpose_model_methods()after make finishes (R/model.R:963,:966) and change no make flag and no byte of the executable, which is why neither appears incompile_impl()'s signature. Both are already dropped in silence whenever the executable is current, because$compile()returns at:804and the exposures sit past it, so the same call populatesfunctionsor not depending on whether a rebuild happened to be needed. The replacements are the ones their own roxygen already recommends:fit$init_model_methods()(:551) and$expose_functions()(:556).fit$init_model_methods()fails on the same reuse path today, since the model C++ it compiles from is generated only inside the build branch (R/model.R:848) and the fit copies an empty environment;test-model-methods.R:108asserts that error. Design note: v1.0 compilation state and C++ options #1254 §5 puts that C++ in the construction snapshot on both paths ("The model's generated C++ is part of the snapshot, for the same reason"): the sameget_standalone_hpp()call, made before the build-or-reuse branch instead of inside it, written to a tempfile for$hpp_file()as the build branch does today.test-model-methods.R:108flips to expecting success. One combination test: construct on a current executable,$sample(),init_model_methods(),log_prob()returns a finite value. Drop the roxygen caveats that$hpp_file()errors when an executable was reused (R/model.R:446-448,:478-480).$expose_functions()must be fixed in the same pull request, because removal makes it the only route and it fails on the same path:expose_stan_functions()refuses whenfunction_env$existing_exeisTRUE(R/utils.R:1217), and:267,:299and:786together leave itTRUEfor a source-backed model whose executable is up to date, socmdstan_model("m.stan")followed by$expose_functions()errors "Exporting standalone functions is not possible with a pre-compiled Stan model!" about a model that has a source. Makeexisting_exemean "this model has no source", and generate the hpp on demand from the registered source. 16 test references to update, acrosstest-model-expose-functions.R,test-model-methods.Randtest-fit-shared.R. Breaking, needs a NEWS entry and migration text, and the migration text has to name the pass-through:brm(stan_model_args = list(...))and instantiate's...both forward tocmdstan_model(), so scripts break through packages that never mention either argument - $expose_functions() reports "pre-compiled Stan model" for a model that was never compiled #1245: moved here from "independent" by the design review, which is right that this stage subsumes it. Its discriminator dissolves:
hpp_codeoff the internal build call is the model's generated C++ on both paths, so a source-backed model always has it and an executable-only model never does (Design note: v1.0 compilation state and C++ options #1254 §8, §5), which answers the "is there generated C++?" half the issue asks for, and the other half stops existing oncecompile = FALSEand publicdry_rungo, since a model object with no executable becomes unconstructable. The guard belongs to the item above: Design note: v1.0 compilation state and C++ options #1254 §8, "$expose_functions()is fixed here too, since removal makes it the only route". What is left over is theexisting_exetohas_generated_cpprename across 13 sites, and it should not land first: the issue plans the rename and the guard fix as one edit because the field's only read sits directly above the message, and this stage changes that read to a different question, whether the model has a source. Its user-visible half is reachable today on the ordinary path and could ship earlier as a plain bug fix: measured on 2.39.0,cmdstan_model(f)twice followed by$expose_functions()errors with "not possible with a pre-compiled Stan model" on a model whose source is beside it, nocompile = FALSEinvolved. That buys earliness rather than correctness, since this stage replaces it with generating the hpp on demand. Outcome:existing_exeis gone rather than renamed;expose_stan_functions()asks whether the model carries generated C++ (hpp_code), and a model built from a Stan file always does - The selected-installation check, immediately before
makeor a tool is invoked out of it rather than as a general precondition (Design note: v1.0 compilation state and C++ options #1254 §6, "A selected installation that is gone is its own error, checked where it is used").makeruns in the selected installation (R/model.R:862-866) and so does stanc, whichstanc_cmd()names relatively asbin/stanc(R/utils.R:123-129): the build, the re-resolution an assessment needs whenever the selection is also the recorded builder, the construction-timestanc --info(R/model.R:2673) that §5's snapshot is taken from, and$check_syntax()(:1151) and$format()(:1278) with their standalone twins, which conduct no assessment and reach it anyway. It reaches past the model object as well:fit$cmdstan_summary()andfit$cmdstan_diagnose()runbin/stansummaryandbin/diagnosewith the selected installation as their working directory (run_cmdstan_tool(),R/run.R:316) and build them on demand with amakein that same tree (check_target_exe(),:414). Both live onCmdStanFit, so a check written into the build path, or intoCmdStanModel$initialize(), never reaches them. Each must fail with a message naming the installation rather than reachingcannot start processx process 'make' (system error 2, No such file or directory), which is what they reach today:set_cmdstan_path()checks the directory once and caches it (R/path.R:69-77) andcmdstan_path()never rechecks (:93-100). Five integration tests, and the set is the point. A source-backed model whose selected installation has been removed errors before stanc or make.$check_syntax()andcheck_syntax_stan_file()on that same model error the same way, which is the case a check written into the build path passes and the source-only operations fall through. An already-constructed source-backed model answers$variables()with that installation removed, whilestan_variables()on the same file errors (Design note: v1.0 compilation state and C++ options #1254 §6, "$variables()is not on that list andstan_variables()is"); the pair is what proves the snapshot is eager, since a$variables()still parsing from disk on first call (R/model.R:1041) needs the installation and so fails one half of it. A fit tool is the one representative for the sites off the model object:fit$cmdstan_summary()with the installation removed errors naming it, where today it reaches the samesystem error 2. And an executable-only model with a valid record and an intact recorded builder still constructs, still answersstan_build_info()(stage 5 adds that assertion), and still samples with that same selected installation removed, since it neither builds nor re-resolves and on Windows takes its TBB from the recordedtbb_dir(Launch the model executable with the TBB its build resolved #1261; Design note: v1.0 compilation state and C++ options #1254 §6, "It is not a precondition on holding a model"). Without the last, a check placed in the shared record-reading path, or at the top ofinitialize(), passes the first three and refuses the packaged models Design note: v1.0 compilation state and C++ options #1254 §7 exists to admit - edits to included files are not detected #1237: edits to included files
- Document the dependencies cmdstanr does not track #1257: the user-facing documentation of what we do not track. The detection, the field and the write-time note moved to stage 3; the
stan_build_info()display is stage 5 - Source-only operations always pass
--allow-undefined; only the build entry points derive it fromuser_header(Design note: v1.0 compilation state and C++ options #1254 §8). Applies to$format(),$check_syntax(),$variables()and their standalone counterparts, so a retained method and its twin cannot disagree.eeed5baf'sif (private$using_user_header_)conditionals become unconditional and the dependency onusing_user_header_leaves all three. Accepted cost, documented rather than filed later:check_syntax_stan_file()passes wherecompile_stan_file()fails, for a function declared, never defined, with no header - One shared resolver for the
dirname(stan_file)include default. When a program has#includeand noinclude_paths, cmdstanr defaults them to the model's own directory (R/model.R:293-297); stanc does not do this itself and fails outright without it.instantiate::stan_package_compile()passes no include paths, so every instantiate package with a multi-file model relies on it, and dropping it turns their installs into build failures. Today the default is shared through object state, with$format(),$variables()and$check_syntax()all reaching it viaself$include_paths(), but three of the five new entry points have no object, so it has to move into a plain function all of them call. All four source-taking functions carry aninclude_pathsargument (Design note: v1.0 compilation state and C++ options #1254 §8); without itformat_stan_file()could not format any program containing#include, which would be a regression on$format(). Note this is a small gain over the methods:$format()and$variables()have no such argument today and readself$include_paths()instead. The resolver runs before the request is recorded, so the record holds the effective value. It is user-visible behaviour and belongs in the public docs, not only in implementation notes - Compile against the real source path (
--filename-in-msg).R/model.R:823-824compiles atempfile()copy, so every runtime exception from every model names a file that was deleted before the user could reach it: correct line and column, useless filename. A live bug in released cmdstanr, never filed. Inject when absent; a caller-supplied value instanc_optionswins untouched, and either wins over a value inmake/local'sSTANCFLAGSunder stage 1's rule, which matters here because 2.38 and 2.39 refuse the repeated flag. Only the two build entry points need it, since the source-only ones already run stanc against the real file. Verified accepted on CmdStan 2.27 through 2.39, andcmdstan_min_version()is 2.35 (R/path.R:145), so no version guard is needed. Noformat_versionbump for the injection: cmdstanr changing what it injects never rebuilds a binary that already exists, and the caller asks for the new behaviour withforce_recompile = TRUE(Design note: v1.0 compilation state and C++ options #1254 §4, "A change to which options cmdstanr injects does not rebuild anything already built"). Bumping instead would recompile every model on every machine to deliver a diagnostic string, and would cost executable-only models their provenance for as long as they are installed, since they cannot rebuild at all (Design note: v1.0 compilation state and C++ options #1254 §7, "That exception is also who pays when aformat_versionis not readable"). Pin the rule with a stage 3b fixture rather than leaving it implicit: a record whosestanc_options_injectedlacks--filename-in-msg, against a current request that injects it, must not rebuild. Separately, stage 4 maderequest.stanc_options_inheriteda required field of format 1 without a bump, since no record has shipped and format 1 is what 1.0 ships: a record written before the field is unreadable, so a source-backed model rebuilds once and an adopted executable falls back to<exe> info. Needs its own NEWS entry and test for the fix. Outcome: under WSL the injected path is the spelling stanc can open (/mnt/c/...), which the record and the generated C++ then carry; everywhere else it is the Stan file as R spells it - Capture
$variables()eagerly at construction. It currently parses from disk on first call (R/model.R:1041), so the answer depends on whether anyone happened to ask before an edit, while$code()is already eager (:272), letting the two accessors describe different versions of the program. Construction is the one moment source and executable are guaranteed to agree, and thestanc --infocall made there for include re-resolution already returns the variable information in the same response -
pedantic = TRUEmust run the check even when nothing rebuilds, which makes it behaviourally significant on the no-op path and meanscompile_impl()has to carry it (Remove deferred compilation and $compile(); add standalone file operations #1256). It is injected as--warn-pedantic(R/model.R:672-673) and is not compared, being a per-call request rather than build state (Design note: v1.0 compilation state and C++ options #1254 §4), so it cannot trigger a rebuild. But skipping the build must not mean skipping the check, or the user asks to be warned and gets silence
Call sequences. The review of #1254 found its defects in combinations of rules, not in single rules, so the sequence tests are listed here as a list, each one line, so a missing combination reads as a gap. The detailed items above and in stage 3b carry the fixtures; this is the index of them.
- Object A builds; the same executable path is rebuilt by another process; A's
$sample()errors on the artifact hash (the replaced-executable case above) - Build; overwrite the source with different content and restore its mtime; construct again rebuilds (the mtime case above)
- Build with
include_paths = "v1/"; construct with"v2/"rebuilds (Design note: v1.0 compilation state and C++ options #1254 §6, "Re-resolution uses the include paths supplied on the current call, not the recorded ones") - Build; move source, executable and record together; construct again does not rebuild (Design note: v1.0 compilation state and C++ options #1254 §6, "A whole project moving changes every path and no hash")
- Build;
$format(overwrite_file = TRUE);$sample()errors; construct again rebuilds (Design note: v1.0 compilation state and C++ options #1254 §5, "reformatting forces a recompile") - Build; construct again with
pedantic = TRUE; nothing rebuilds and the stanc check still runs (thepedanticitem above) - Construct on a current executable; edit an included file;
$sample()errors naming that file; construct again rebuilds;$sample()runs. edits to included files are not detected #1237 covers the trigger; this covers the sequence through §5's error and the constructor's rebuild - Construct on a current executable;
$sample(); edit the source so the log density changes;fit$init_model_methods();log_prob()returns the original program's value, not the edited one (Design note: v1.0 compilation state and C++ options #1254 §5, "The model's generated C++ is part of the snapshot, for the same reason") -
$sample(threads_per_chain = 4);$sample()with the argument omitted reportsmetadata()$threads_per_chainof 1 and the session'sSTAN_NUM_THREADSis unchanged (Design note: v1.0 compilation state and C++ options #1254 §1, "Removing the error exposes a leak, and the count moves to the child process") -
make/localwithSTANCFLAGS += --warn-pedantic;cmdstan_model(f, pedantic = TRUE)builds with the flag once, on 2.37 as well (Design note: v1.0 compilation state and C++ options #1254 §6, "A flag the call emits wins over the same flag inmake/local'sSTANCFLAGS") - Unthreaded executable on disk; construct with
cpp_options = list(stan_threads = TRUE)rebuilds;$sample(threads_per_chain = 2)reports 2; construct again with nocpp_optionsrebuilds again, unthreaded (Design note: v1.0 compilation state and C++ options #1254 §2: options are supplied on every build call and never accumulate). This is the cost brms users pay when they toggle threading, pinned as intended
With the engine already built and tested in stage 3b, what remains here is the wiring and the API removal, the two things that change what a user sees, reviewed together and without the decision table underneath them still being argued about.
Done in #1273, merged to v1.0 as 97030b4 on 2026-09-16 (two Codex review rounds, conditional sign-off on 2026-09-15, CI green on all fifteen jobs, the first run with CmdStan 2.40.0 on both sides). It closes #1255, #1256, #1237, #1245, #1253, #1019 and #1249 and ticks #1257's stage 4 boxes.
Three follow-ups landed on v1.0 after it: the readRDS exposed-functions fix and the test-directory tidy as direct commits, #1275 (a build and a syntax check leave nothing in the temp directory, 99b997d) and #1277 (the record's fields and the code around them renamed, cda4315). The names in force from #1277 on, used by stage 5 below: request is configuration, holding cpp_options, stanc_options, stanc_options_added, stanc_options_from_make, stanc_name and include_paths; the record's artifact is executable_hash; builder is cmdstan; known_untracked_dependencies is untracked_dependencies; the public result's provenance is record, with the reasons missing, unreadable, unsupported_format and executable_mismatch.
Stage 5: public build-record inspection (merged to v1.0 in #1278, c52d0ef)
-
stan_build_info(exe_file), the reader: find the record beside the executable, verify the hash bond, and translate the record into a public result. Notjsonlite::fromJSON()output. The on-disk schema is private andformat_versionexists so it can change (Design note: v1.0 compilation state and C++ options #1254 §4, "Format versions"), so handing the parsed record back would make every private format change a public API break - The public field list, named and fixed, with the reader translating onto it (Design note: v1.0 compilation state and C++ options #1254 §8, "
stan_build_info()returns a public result, not the parsed record"). The result is narrower than the record by design: a field can be added in a later release and cannot be removed or reshaped once it has shipped, and nothing has shipped yet (Design note: v1.0 compilation state and C++ options #1254 §8, "A field is public only if a caller can act on it"). The executable hash, everyhashunderdependencies,stanc_options_added,stanc_nameandtbb_dirstay in the record and are absent from the result. Assert the absence, not only that the surviving fields are right. The natural implementation renames fields off the parsed record and carries the withheld ones along for the ride, and a test that checks the public fields have the right names and values is green against that implementation - The result carries class
"stan_build_info", one name and not a vector (Design note: v1.0 compilation state and C++ options #1254 §8, "The result is a list with class"), withS3method(print, stan_build_info)reaching NAMESPACE through roxygen. This is the package's first S3 class of its own: today's fifteenS3methodlines are allas_drawsandprocess_initdispatching on R6 class names, and there is noprintmethod anywhere inR/ - The nested names and types, not only the top-level seven (Design note: v1.0 compilation state and C++ options #1254 §8, "The nested names are settled here rather than by whoever implements it"). Three shapes an implementer would plausibly get wrong. The user header sits under
dependenciesand nowhere else, in the record as well as in the result, so one normalised path is never stored twice (Design note: v1.0 compilation state and C++ options #1254 §8, "The user header appears once, underdependencies"); assert it against a model built with a real user header, since a no-header fixture returnsuser_header = NULLand passes whatever the implementation does when there is one. Anddependenciescoversmake/local, which is a dependency with its own trigger rather than part ofcmdstan, and isNULLwhen the installation had none (Design note: v1.0 compilation state and C++ options #1254 §8, "make_localisNULLwhen the installation had none") -
untracked_dependenciesentries arelist(kind, detected_in),kindfrom a fixed pair (make_local_include,user_header_include) anddetected_inthe file the regex matched in rather than the include it could not resolve (Design note: v1.0 compilation state and C++ options #1254 §8, "A known untracked dependency says which gap and where it was found, never what it points at"). The unresolved target is not a field at all: §6 declines to resolve it, so anything stored there would be a guess some of the time. Asserting both kinds needs two fixtures, amake/localcarrying an-includeline and a user header carrying a quoted#include, and the assertion is ondetected_inas well askind, since a fixture with one gap passes an implementation that hardcodes the other file. One entry per distinct(kind, detected_in)pair, ordered bykindthendetected_in(Design note: v1.0 compilation state and C++ options #1254 §8, "One entry per distinct(kind, detected_in)pair, ordered bykindthendetected_in"), so a third fixture is needed: a user header with two quoted includes, which yields one entry and not two, since nothing in an entry distinguishes the matches. Ordering needs a fourth assertion but no fourth fixture: hand the assembling function both entries withuser_header_includefirst and requiremake_local_includefirst coming out. Each fixture above produces a single kind, so none of them can fail if the order is wrong -
reported_featureswith fixed names andNAfor unknown, which is not the record's presence encoding (Design note: v1.0 compilation state and C++ options #1254 §8, "reported_featureshas fixed names, and unknown isNA"). Nothing in the result round-trips through a file, soNAkeeps its type. It buys no louder failure than the record's encoding does, sinceisTRUE()readsNAand a missing key alike asFALSEandif ()errors on both. What it buys is a stateis.na()can ask about, where a missing member answerslogical(0), and a fixednames(reported_features)a test can hold. The names are the four booleans<exe> infoprints plusstan_version, always all present, and the two types differ: the flags are logical,TRUE,FALSEorNA, whilestan_versionis a character scalar orNA_character_. Assert the type, not only the value, or an implementation returning"TRUE"passes -
recordaslist(status, reason)with a machine-readable reason enum (missing,unreadable,executable_mismatch,unsupported_format), which is §7's four forms made machine-readable rather than a new taxonomy.availablerequiresreason = NULL,unavailableexactly one reason, both names present either way sonames(record)is a fixed pair, and no free-form message is stored: the printer derives prose from the enum, including the direction forunsupported_format, which runs both ways (Design note: v1.0 compilation state and C++ options #1254 §8, "recordcarries why, not only whether") -
executable_mismatchwithholdsconfiguration,dependenciesandcmdstaneven though they parsed, returning the reason and nothing else derived from the record (Design note: v1.0 compilation state and C++ options #1254 §8, "A readable record whose hash does not match is read only to say why").reported_featuresstill comes back, read off the executable, which is the general rule and not an exception to this one (Design note: v1.0 compilation state and C++ options #1254 §8, "Unavailable provenance still reports the binary's own features") -
format_versionis present forunsupported_formatand absent for the other three reasons (Design note: v1.0 compilation state and C++ options #1254 §8, "format_versionis public only underunsupported_format"). It is the one field kept public without a caller who acts on it, because the printer has no other source for the direction message andprint.stan_build_info(x)receives nothing butx. The absence underunreadableis whole-record withholding rather than a claim about what could be parsed: an unreadable record reports none of its contents, including aformat_versionthat parsed perfectly well (Design note: v1.0 compilation state and C++ options #1254 §4, "What the reader cannot use, it rejects as unreadable rather than working with"). The case that separates the two readings is a supported-version record carrying one malformed required field, which stage 2 already fixtures. Assert here that it yields noformat_version(Design note: v1.0 compilation state and C++ options #1254 §6, "Unreadable means the record could not be accepted as a record at all"). Assert its mirror too, since the two are one rule seen from either end: an unsupported-version record whose body is invalid under the current schema reportsunsupported_formatand its version, because the fields are never checked against a schema this cmdstanr does not have (Design note: v1.0 compilation state and C++ options #1254 §4, "The version is checked first, and on its own") -
cmdstanis present whenever provenance is available, with no "if one was recorded" condition. §4 records it for every record and §7 requires a usable record to carry a parseable version, so the conditional describes a state that cannot exist. A recorded builder whose directory is gone isexists = FALSE, which is a different thing - The provenance state, including the unusable-record path: unavailable provenance returned together with the
reported_featuresthe binary supplies, never an empty result that reads as "nothing was configured" (Design note: v1.0 compilation state and C++ options #1254 §7, "Executable without a usable record") -
configurationandreported_featuresreported as §1 separates them, never merged - Unavailable information distinguished from a valid empty value, throughout the result.
untracked_dependenciesempty because the scan found nothing is not the same object as no record to scan; a recorded builder whose path is gone is not the same as no builder provenance; an unknown request is not an empty one. Absence of evidence is not evidence of absence (Design note: v1.0 compilation state and C++ options #1254 §6, "The field isuntracked_dependencies, notprovenance_complete"), stated there as a property of one record field and applying here to the whole result - Each dependency with its
built_frompath and whether that path still exists. The existence flag reads as normal rather than as a fault (Design note: v1.0 compilation state and C++ options #1254 §9, "The existence flag is a neutral fact, not a warning"), and the function never tries to resolve where the file lives now - Existence answered as of the call: evaluated while the result is constructed, a
file.exists()call per dependency path, and the returned values are a snapshot. Same for the builder's flag - The
cmdstaninstallation and version, anduntracked_dependencies: populated in stage 3, displayed here - A print method. Its floor, below which nothing is deferrable: provenance status; reported features with unknown distinct from false; known untracked dependencies; and missing dependency and builder paths rendered neutrally. That last one is a doc rule rather than a preference: Design note: v1.0 compilation state and C++ options #1254 §9's existence-flag passage is written about rendering, and its own example is a maintainer asking a user for
stan_build_info()output and getting a healthy installation back with every dependency flagged - Reference documentation. Its floor: the public field list; the tri-state representation; unavailable-provenance behaviour; absent information versus an empty recorded value; and why a missing
built_fromis normal for install-time builds, which Design note: v1.0 compilation state and C++ options #1254 §9 already requires in as many words -
_pkgdown.ymlentry forstan_build_info. Not documentation polish: the reference index is an explicitcontents:list, pkgdown errors on topics missing from it, and.github/workflows/pkgdown.yamlruns on CI, so an exported function with no entry fails the build - Test scenarios: a valid record; an unusable one in each of its four forms, asserting the matching
reason; a dependency whosebuilt_fromno longer exists; a recorded builder that is absent; a non-emptyuntracked_dependencies; a missing path; an unlaunchable executable with no record; and an unlaunchable executable that has a valid hash-matched record, which must return the recorded information without ever running the binary - Test assertions, as additional
expect_*inside those scenarios rather than as new files. The scenarios name inputs and pin nothing on their own: a reader returning unavailable provenance for every input, valid records included, passes all of them - Two of those assertions have to be written a specific way or they pass while the bug is present. For
executable_mismatch, assert where the features came from rather than that the field is populated: make the record saystan_threads = TRUE, make<exe> infosayfalse, requireFALSE. Filling the field from the rejected record is the natural implementation and "features retained" is green against it, while §1 then points the runtime validators at aTRUEbelonging to some other build. For the valid-record case, assert the binary is never launched: mockrun_info_cli()with a counter and require it to stay zero. A mock that only throws is not enough, becausereported_features_from_exe()catches the error and returns unknown features, so an unnecessary launch whose result is discarded stays invisible.run_info_cli()(R/build_record.R) is the only place anything runs<exe> info. Neither test compiles anything: any file at the executable path can be hashed and bound to a fabricated record, so a file that could never run is a complete fixture
| must be true | rule |
|---|---|
| exact public field names and nesting | #1254 §8, "stan_build_info() returns a public result, not the parsed record" |
| the withheld record fields are absent from the result | #1254 §8, "A field is public only if a caller can act on it" |
a build with a real user header carries the path under dependencies only |
#1254 §8, "The user header appears once, under dependencies" |
configuration and reported_features never merged |
#1254 §1, "Request and reported features are never merged into one accessor" |
| tri-state preserved: known true, known false, unknown | #1254 §1, "tri-state and best-effort" |
| all four unusable forms give unavailable provenance, features retained | #1254 §7, "Executable without a usable record" |
both untracked kinds reach the result with detected_in naming the matched file |
#1254 §8, "A known untracked dependency says which gap and where it was found, never what it points at" |
| a valid record with nothing detected differs from unavailable provenance | #1254 §6, "The field is untracked_dependencies, not provenance_complete" |
non-empty untracked_dependencies reaches the result and the printer |
#1254 §6, "Surface it when the record is written, and through stan_build_info()" |
a missing built_from gives exists = FALSE, no warning, no unhealthy status |
#1254 §9, "The existence flag is a neutral fact, not a warning" |
| a recorded-but-missing builder differs from no builder provenance | #1254 §8, "Unknown and empty must never render alike, anywhere in the result" |
each unusable form reports its own reason, and available never carries one |
#1254 §8, "record carries why, not only whether" |
executable_mismatch withholds the record fields it could have read |
#1254 §8, "A readable record whose hash does not match is read only to say why" |
reported_features for a mismatched record come from the binary, not the record |
#1254 §8, "Unavailable provenance still reports the binary's own features" |
the printer reads the direction for unsupported_format off format_version |
#1254 §8, "unsupported_format is not evidence that cmdstanr is old" |
| printing preserves unknown versus false | #1254 §1, "tri-state and best-effort" |
| a missing path errors; an unlaunchable one with no record errors rather than returning all-unknown | #1254 §8, "The exe_file argument has two failure modes and both are errors" |
an unlaunchable executable with a valid record is answered from the record, with zero <exe> info calls |
#1254 §8, "The exe_file argument has two failure modes and both are errors" |
the result carries class "stan_build_info", and the print method dispatches on it |
#1254 §8, "The result is a list with class" |
the nested names match, including the user header under dependencies and make_local |
#1254 §8, "The nested names are settled here rather than by whoever implements it" |
unknown features are NA, not a missing key; every fixed name present; the four flags logical and stan_version character |
#1254 §8, "reported_features has fixed names, and unknown is NA" |
format_version present for unsupported_format and absent for the other three reasons: an unsupported-version record whose body is invalid under the current schema still yields its version, while a supported-version record with one malformed required field yields none, and so does an executable_mismatch whose record parsed cleanly |
#1254 §8, "format_version is public only under unsupported_format"; #1254 §4, "The version is checked first, and on its own" |
both kinds in one result come back make_local_include before user_header_include, whichever order they arrived in |
#1254 §8, "One entry per distinct (kind, detected_in) pair, ordered by kind then detected_in" |
| a user header with two quoted includes yields one untracked entry, not two | #1254 §8, "One entry per distinct (kind, detected_in) pair, ordered by kind then detected_in" |
cmdstan present for every available provenance, exists = FALSE when its path is gone |
#1254 §8, "Unknown and empty must never render alike, anywhere in the result" |
There is no "executable-only model" scenario. stan_build_info(exe_file) receives a path, and a path cannot say whether some R object elsewhere was built with exe_file = or from source. Executable-only is a §7 construction mode whose distinctive behaviour is $cpp_options() hydrating from the record, which is a model method and stage 4's to test. From this function's side there are two inputs and both are already above.
Last because it publishes answers stage 4 settles. Its inputs exist a stage earlier, since stage 3 writes the record and captures reported_features, so this is not about availability. Until stage 4 deletes the merge, $cpp_options() still answers "what is this binary" by mixing the report into the request, so publishing here would put the function into a world where its own purpose is not yet true and stage 4 would then change what it reports; and it has to answer for an unprovenanced executable, which record-aware adoption does not create until stage 4. Because $cpp_options() reports the request and never merges what the binary says, this is the only way to ask what an executable is, and the only answer available at all for one with no usable record.
It must land before the release candidate. Stage 4's NEWS entry for $cpp_options() names this function as where the reported-state meaning went, so a candidate without it ships release notes pointing at an error. The old "stabilises under candidate use" rationale is retired rather than reconciled: the function is public from the candidate on, so the candidate period cannot be what stabilises it. What survives is narrower and is ordinary candidate discipline: from the tag onward its output may gain fields, and the dependency reporting is expected to, but may not rename or remove one. Estimate and decompose it before starting; the contents are not the variable. This was the one stage sized by guesswork, every other being a list of named changes while this was "write the function", which is why it is a list of deliverables above instead. An overrun should become visible while there is still time to act on it, not at the candidate date. All of that is 1.0, and the scope is not the tracker's to reopen: #1254 is canonical, and four of its rules already cite specific fields of this report: §6's "Surface it when the record is written, and through stan_build_info()" puts the untracked-dependency property here, §7's "Executable without a usable record" requires unavailable provenance to come back together with reported_features, §9's "The existence flag is a neutral fact, not a warning" governs how the built_from flag reads, and §9's "cmdstanr cannot repair an install-time-built model" predicts the 00LOCK-… build path instantiate users will see. Ship half and four rules stop being true. The record is not public, so there is no other supported route to any of it. Splitting it as a rescue when it overruns is the thing to avoid, because that reintroduces the safe-before-or-after-the-tag adjudication the release order exists to remove.
If it does overrun, what gives is everything above the floor, and the floor is written down. Fixed: which fields exist and what they are named; the translation from the private record onto them; tri-state preservation; unavailable provenance paired with reported_features; unavailable information distinguished from a valid empty value; existence answered as of the call; and the two floors above, the minimal printer and the assertion table. Those last two are not contract but are what delivers the contract to a human and what verifies it holds, and a contract with neither is a contract on paper. Above the floor, and therefore compressible: the printer's colour, alignment, truncation and wording; vignette and tutorial material past the reference page and the NEWS entry; tests past the assertions; and performance work, after measuring rather than before.
A dial that changes what the function returns, or when its values were true, is not a dial. How built_from existence gets computed was on this list last round, as lazily or once-and-cached rather than eagerly, and it is removed. The flag answers whether a path exists now, so a cached answer is a stored verdict standing in for an observation, which #1254 §5 prohibits, and the lazy variant buys an object whose fields are not all populated until something touches them. What it saves is one vectorized file.exists(). Writing the frame down rather than the list is what keeps the next candidate dial honest: under deadline the obvious move is to ship fewer fields, and that needs a pre-agreed answer which is not "sometimes".
Done in #1278, merged to v1.0 as c52d0ef on 2026-09-19. Four follow-ups landed on v1.0 after it: #1280 (the error for an executable that cannot be run names it, 76aaede), #1281 (a model executable launches with the TBB it was built against, b321829), #1283 (stanc_options_from_make reports the stanc flags make/local added, 90db219) and the NEWS reconciliation below as a direct commit (eb7164d).
Before the release candidate
This section is the NEWS inventory. Every stage item gets one of two dispositions: a line here, or a stated reason it needs none. Silence is not a third option. The earlier version of this check walked only items that already said "needs a NEWS entry" and confirmed each had a line, which cannot catch the failure it exists to catch, since an author who did not think about NEWS leaves nothing for the check to find. It missed six: the four in the round-13 review plus two more below. Items that change nothing a user can see say so in the item, as the stage 3 injection refactor does ("behaviour-free on its own"), and the pass confirms that claim rather than trusting an absent label.
Running the check is a step in the reconciliation pass below, not a property this section asserts about itself. Stated and unenforced, it had already drifted twice before that.
The list is not only removals, and keeping it that way takes an effort the inventory does not make on its own. Harvesting entries from the stage items produces removals and rejections, because that is what the stages are. The headline feature, that cmdstanr knows whether your executable matches your model, appears in no stage item under that description, and neither do the accessor and behaviour changes below. Those are written from the design.
-
$compile()and deferred compilation are removed (Remove deferred compilation and $compile(); add standalone file operations #1256), with the standalone family as the migration. This is the headline break of 1.0 and was tracked nowhere: Remove deferred compilation and $compile(); add standalone file operations #1256 does not mention NEWS, and the reconciliation item below removes the fifteen-plus entries describing$compile(), so as planned the release notes would delete every mention of the method without ever saying it went. Name each replacement:mod$compile()andcompile = FALSEtocmdstan_model(), or tocompile_stan_file()where the caller wants a path rather than a model (Design note: v1.0 compilation state and C++ options #1254 §7, "Executable-only models are kept, and adoption has three outcomes", treats that as a first-class pattern);mod$check_syntax()tocheck_syntax_stan_file();mod$format()toformat_stan_file();mod$variables()tostan_variables(). The executable-only check from Stage 1 gets its abbreviated-argument tests here (force = TRUE,cpp_opt = list(),ped = TRUEbesideexe_filewith nostan_file, each an error), since the constructor owning the formals is what closes them -
compile_model_methodsandcompile_standaloneare removed, withfit$init_model_methods()and$expose_functions()as the migration. Keep it separate from the$compile()entry above rather than folding it in: that entry is about deferred compilation, while these two are post-build actions that never configured anything. Say plainly that both were silently ignored whenever the executable was already up to date, since a user who relied on them and never hit a rebuild will otherwise read this as losing something that worked. The entry also has to reach users who never named either argument, becausebrm(stan_model_args = )and instantiate's...forward them - Model executables now run against the TBB their own build resolved (Launch the model executable with the TBB its build resolved #1261), which is the CmdStan installation that built them unless the build named its own TBB. Windows only in effect, since elsewhere the binary carries an absolute rpath and cmdstanr supplies nothing. Previously the session's current installation supplied it, so a model built under one CmdStan and sampled after
set_cmdstan_path()loaded another CmdStan's TBB. Where the recorded directory no longer exists cmdstanr now supplies nothing rather than substituting, so such a model reports a launch error instead of silently loading a TBB it was not linked against -
cmdstan_model(stan_file, exe_file)is now an error (Design note: v1.0 compilation state and C++ options #1254 §7, "stan_fileandexe_filetogether are an error"). Both are accepted today, andexe_filethere is not adoption: it names the build destination, filename included, so a stale binary at that path is rebuilt over rather than used as it stands. Namediras the replacement and be exact about what it does not cover:dirsets the directory while the filename comes from the.stanname, or fromwrite_stan_file(basename = )for generated code, so naming two builds of one program inside a single directory now takes a subdirectory.?cmdstan_modelneeds rewriting either way: it currently callsexe_filean existing executable that can be supplied in addition tostan_file, and both halves cannot hold - The
$exe_file(path)setter is removed (Design note: v1.0 compilation state and C++ options #1254 §5). It assignsprivate$exe_file_with no validation, snapshot refresh or provenance update, so under this design it would leave an object holding a record describing a different binary. The getter stays. Its one call site is the directory-destination test attest-model-compile.R:1526, which covers a guard that stays,dirstill resolving onto a directory, so rewrite that test to reach the guard throughdirrather than dropping it with the setter (exe_file_ conflates the installed executable with the planned build destination #1253) - Arguments supplied beside
exe_file =with nostan_filethat can only be honoured by building or by reading the source are now an error (Design note: v1.0 compilation state and C++ options #1254 §7):cpp_options,stanc_options,include_paths,user_header,force_recompile,pedantic. Kept separate from the consolidated channel-rejection entry below, which is about which channel a setting uses rather than about asking for work there is no source or build to do - One consolidated NEWS entry for the channel rejections, not one per setting. The migration is a single concept, that each of these settings now has exactly one channel, and someone who hits one is likely to hit others, so separate bullets read as unrelated breakages. Name each rejected spelling with its replacement:
include-pathsinstanc_optionsormake/localSTANCFLAGStoinclude_paths;warn-pedanticinstanc_options, however spelled, topedantic;allow-undefinedinstanc_options, now derived fromuser_header;use-openclinstanc_optionstocpp_options = list(stan_opencl = TRUE);USER_HEADERanduser_headerincpp_optionsto theuser_headerargument;STANCFLAGSincpp_optionstostanc_options;nameinstanc_optionsto naming the file, which is where the model name has always come from. The include-path one is a bug fix rather than a removal and should say so: those models compile today and then fail on$sample(). Name$user_header()here too: the header now has one channel in and one accessor out, where reading it back previously meant$cpp_options()[["USER_HEADER"]], which no longer contains it. Checked: neither brms nor rethinking uses any of them - Its own NEWS entry for
$cpp_options()reporting canonical names, kept separate from the consolidated entry above because it is an accessor change rather than a migration. Today the accessor reports the caller's spelling and the binary's, sincemerge_exe_info_cpp_options()writes reported names in upper case over the request (R/cpp_opts.R:83), solist(stan_threads = TRUE)comes back asstan_threadsandSTAN_THREADSboth. After canonicalization it is one entry,STAN_THREADS. Two things break and the second is silent: indexing the lower-case name returnsNULL, while indexing the upper-case name keeps working and changes meaning, from a value the binary confirmed to one the caller asked for. Namestan_build_info()as where that meaning went. Test on ordinary construction and on record-backed adoption - The feature itself: cmdstanr records how each executable was built and rebuilds when the source content or the build configuration no longer matches, replacing the timestamp comparison (Make compile(..., cpp_options()) consistent with makefile options #1019, Rebuild when the recorded build no longer matches what was asked for #1255). Editing an included file now rebuilds (edits to included files are not detected #1237), which is the same news from the user's side, so one entry rather than two. Say what is lost as well:
touch file.stanno longer forces a rebuild, since nothing compares timestamps any more, andforce_recompile = TRUEis what replaces it - Adopting an executable with
exe_file =that cannot be run now gives one error naming the path, where today the outcome depends on how it fails: a file with the execute bit and unrunnable contents constructs a model that reports the session's CmdStan version as its own, and one without the bit surfaces a rawprocessx_execerror (cmdstan_model(exe_file = ) surfaces a raw processx error when the executable cannot be run #1246). cmdstanr needs the version that built the binary to construct its command line, and the only way to lack it is an executable that does not identify itself as a supported CmdStan one, which a real CmdStan binary cannot do, sinceinfohas printedstan_version_*unconditionally since 2.27, eight releases below cmdstanr's floor. Nothing that would have sampled successfully is refused. Say "did not identify itself" rather than "did not run": a program that runs fine and prints nothing useful lands here too. Say that a readable build record supplies the version without running anything, so a model that has one is unaffected - Guarded methods error on a stale executable rather than silently running it. Name the ten, and name
force_recompile = TRUEas the override for the cases nothing tracked can see -
$variables()is now a snapshot of the source the executable was built from, captured at construction, rather than parsed from disk on first call. A model whose.stanfile changed after construction reports the built program, not the edited one, which is the contract$code()already has -
$format(overwrite_file = TRUE)no longer refreshes$code(). This is a released behaviour, not an unreleased one: thestan_code_reassignment is commit1719851efrom April 2022 and shipped in 0.7.0 through 0.9.0, so deletingNEWS.md:94as a stale Keep model state consistent with the executable, and stop dropping compile-time inputs #1235 entry removes the only mention of a change 0.9 users will see. The$variables()half of that entry is Keep model state consistent with the executable, and stop dropping compile-time inputs #1235's and does go with it - Reformatting in place now costs a recompile, which is the part users meet (Design note: v1.0 compilation state and C++ options #1254 §5, "reformatting forces a recompile").
$format(overwrite_file = TRUE)rewrites the file, the snapshot keeps describing the built source, the content hash no longer matches, and the next operation that runs the binary errors and points atcmdstan_model()(Design note: v1.0 compilation state and C++ options #1254 §5, "The snapshot must be captured eagerly, or it is not a snapshot"). The entry has to say that rather than that it rebuilds: the rebuild happens on thecmdstan_model()call the error asks for, never inside the operation that noticed, which is the whole of Design note: v1.0 compilation state and C++ options #1254 §5's constructor-rebuilds/operations-error split. Same change as the entry above, opposite end: that one says what stopped, this one says what happens instead -
pedantic = TRUEnow runs the check even when nothing rebuilds. Previously a request that found the executable current skipped the build and with it the check, so asking to be warned produced silence - A record file is now written beside every executable, named after the executable rather than the model:
bernoulliis described by.bernoulli.cmdstanr.json,bernoulli.exeby.bernoulli.exe.cmdstanr.json. Say what it is, that it belongs with the executable rather than in version control, and that deleting it costs a rebuild rather than breaking anything -
stan_build_info(), the new API (stage 5) - The write-time note when a build has dependencies we detected but cannot track (stage 3, Document the dependencies cmdstanr does not track #1257)
- The validator change, claimed at stage 4 and previously listed nowhere:
threads_per_chainagainst a non-threaded build now errors where it warned and discarded, and the converse error for a threading-enabled binary run without it is gone -
--filename-in-msg, claimed at stage 4 and previously listed nowhere: runtime exceptions from newly built models name the real source file instead of a deleted tempfile copy. Existing executables keep the old message until something rebuilds them, and the entry says thatforce_recompile = TRUEis how to ask for the new message now (Design note: v1.0 compilation state and C++ options #1254 §4, "A release that changes what cmdstanr injects says so in NEWS") - Audit the unreleased window for changes that were never written up at all. The release notes belong to the release rather than to whoever caused each line, and this is not our gap. It is inherited, and the candidate is the last moment to close it. Method: extract the PR numbers cited in the unreleased section of
NEWS.md, extract the PR numbers from merge subjects sincev0.9.0, and read the titles of the difference. It over-reports (an entry that describes a change without citing its number looks uncited) and under-reports (a PR cited incidentally elsewhere looks covered), so it produces a list to read rather than a list of defects. Measured today: 103 PR numbers in merge subjects, 32 cited, and after dropping dependabot, CI and docs roughly 25 plausible user-facing changes with no entry. Confirmed by name, each with zero mentions inNEWS.md:print_stan_file()(New functionprint_stan_file()for color formatted Stan code in quarto and R markdown #1166, exported),$cmdstan_defaults()(New cmdstan_defaults() method for getting CmdStan's default argument values #1167),$materialize()(materialize()method for forcing draws and diagnostics (and inits and profiles) into memory #1181), ther_eff = FALSEdefault forloo()(Default tor_eff=FALSEforloo()method #1091),qs2support for saving model objects (Add qs2 option for saving model objects #1125), and the$lp_approx()/$mle()return type change ($lp_approx(),$mle()should return numeric vectors regardless of default draws format #1190). The rest is mostly pathfinder and laplace fixes (For laplace, don't overwrite optimize CSV whenmode=NULLandoutput_basenameis specified #1191, Fix handling ofsave_single_pathsargument for pathfinder #1192, Fix pathfinder column order in draws() #1205, Fix pathfinder initialization duplicate draws edge cases #1208),num_threadsrenamed tothreadsfor pathfinder (Use threads instead of num_threads for pathfinder, consistent with the rest of cmdstanr #1194), the spinner option (Global option to turn off spinner #1224) and include paths with spaces (Fix include paths with spaces and resolve them at model creation #1226). As it stands 1.0 ships a new exported function and two new public methods without announcing any of them - One pass over everything users read, for voice rather than for accuracy: roxygen and man pages,
NEWS.md, vignettes, error and warning messages, and the print method's output. Text written across six stages reads unevenly, and the register it drifts into is not the one cmdstanr is written in. Calibrate against the package's own existing prose rather than against a general standard. Do it alongside the NEWS reconciliation below, which reads the same material for a different reason. Done in Clean up the prose added for v1.0 #1289. - Raise the CmdStan floor to 2.37.0, since 2.36 runs HMC for a model with no parameters and retires the version gate in
$sample(), and 2.37 is the floor [FR] Use new num_chains arg in cmdstan for hmc, fixed params, and generated quantities #534 needs. Done in Drop pre-2.37 CmdStan support for v1.0 #1290 - Decide on [FR] Use new num_chains arg in cmdstan for hmc, fixed params, and generated quantities #534 (CmdStan's
num_chains) and PR Support model methods & standalone functions for models with external C++ (user_header) #1197 (model methods with auser_header), both deferred on 2026-09-23, before the candidate. Decided 2026-10-06: both wait until after 1.0, since each is additive and neither branch is current. [FR] Use new num_chains arg in cmdstan for hmc, fixed params, and generated quantities #534 is on the "future" milestone, Support model methods & standalone functions for models with external C++ (user_header) #1197 stays open as the reference. PRs Improve Handling ofcpp_options#1022 and enhancement to current cpp option code #1099 close whenv1.0merges to master - Reconcile
NEWS.md. The unreleased section carries fifteen-plus entries about$compile(),compile = FALSEanddry_runthat stage 4 deletes, plus one describing a$format()behaviour the design reverses. Entries that no longer apply at 1.0 are removed rather than annotated, since someone upgrading from 0.9 never saw the intermediate behaviour. Do the inventory check here as an action: walk every stage item, not only those claiming an entry, and give each one a line above or a stated reason it needs none. This pass is the last thing before the candidate
Release candidate
Ships after the NEWS reconciliation and Air's format, per the order at the top, so packages built around precompiled models, instantiate most directly, have a working version to migrate against rather than a release note. Everything is in it; nothing is deferred into the candidate period, which is what puts Air before the tag rather than after it.
The candidate is a tag on the v1.0 branch, installed with remotes::install_github("stan-dev/cmdstanr@v1.0") or by tag name, and master stays on 0.9 until the release. The merge to master waits for guarded brms and instantiate releases on CRAN, not for a date: the day master moves, r-universe and install_github() users get 1.0, and brms on CRAN still calls cmdstan_model(compile = FALSE). A pull request merged to master during the candidate period is in 1.0 but not in the tagged candidate, so master is merged into v1.0 by pull request as those land, and whether to tag a second candidate is decided then.
Downstream pull requests
We open these ourselves rather than waiting to be asked. brms, instantiate and rethinking are the priorities. The first two are chokepoints rather than merely important packages: instantiate's own dependents call instantiate::stan_package_model() rather than cmdstanr directly, so fixing instantiate carries its dependency tree with it. rethinking is here for reach rather than fan-out, it is how most people first meet cmdstanr. Everyone else has the candidate period to adapt on their own.
-
rethinking: dropcompile = FALSEfrom threeulam()call sites and fromcstan()'s own signature, and bundle the threading fix with it:ulam()builds every model with threading enabled whether or not it is used, and the guard cannot be restored on its own becausethreads_per_chainis passed unconditionally. The threading fix behaves identically before and after 1.0 so it could go earlier, but one pull request at the candidate avoids asking twice and avoids revisiting threading immediately after Keep model state consistent with the executable, and stop dropping compile-time inputs #1235 changed it Measured, and it is the smallest of the three: it reaches cmdstanr at four places, three inulam()(rethinking/R/ulam-function.R:1424,:1455,:1493) and one incstan()(rethinking/R/cmdstan_support.r:32), all of the formcmdstan_model(stan_file, compile = , cpp_options = , stanc_options = ), and it never reads build state back: no$compile(), nodry_run, noexe_file(commented out at all three sites), none of the guarded accessors, everything else fit-side. Inulam()the argument iscompile = filex[[3]]andfilex[[3]]is hardcodedTRUE(:1398), so deleting the line is the whole fix;cstan()propagates to end users becausecompileis rethinking's own documented argument (rethinking/R/cmdstan_support.r:17), passed straight through. It satisfies Design note: v1.0 compilation state and C++ options #1254 §1's threading policy both before and after, sinceulam()enablesstan_threadsand always suppliesthreads_per_chain, and it has no version-control exposure:tempdir()means records never reach a repository. -
instantiate: adopt withcmdstan_model(exe_file = exe_file)alone, dropping bothcompileandinclude_pathsfrom that call. A final-location source can be registered: after R moves the staged tree the.stanfile is there with identical content, which under content identity would not even rebuild. The reason to leave it out is that registering source hands the rebuild decision to the session, andbuildercompares the CmdStan installation path and version, so the nextinstall_cmdstan()forces a recompile inside a user-facing fit function, into the package library, for a binary that still works (it is self-contained apart from TBB, which it loads through an absolute rpath into the old tree that the upgrade leaves in place). The package owns when its model is built; the user asks for a rebuild by reinstalling the package.include_pathskeeps its meaning on the install-timecompile_stan_file()call. Also: decide the missing-executable branch; update the.gitignoretemplate, which re-includes anything with a dot and would therefore commit the record; add a staged-install integration test that installs an example package the ordinary way, with a real#include, then assertsstan_package_model()is silent, leaves both the executable and record hashes unchanged, and samples The missing-executable branch has no successor and erroring is defensible: that state means a package was installed without its binary. The.gitignoretemplate is the part that will not fix itself. The example package shipsinst/stan/**,!inst/stan/**/*.*,inst/stan/**/*.exe,inst/stan/**/*.EXE: ignore everything, re-include anything with a dot so.stanfiles survive, re-ignore Windows binaries. The rule is built on "extensionless means binary" and the record has an extension, so it is re-included. Verified withgit check-ignore: the executable is ignored,.bernoulli.cmdstanr.jsonis not. Telling users to ignore the record alongside the binary does not help against a pattern that un-ignores it by construction, which is why Design note: v1.0 compilation state and C++ options #1254 §4 asks for an explicit.*.cmdstanr.jsonline. -
instantiate, separately and at any time: dropcompileandinclude_pathsfrom theexe_filecall only.stan_package_model()forwards both to whichever branch it takes (R/stan_package_model.R), and stage 1 makes them an error on the adoption branch. Default calls survive the gap, because the rejection tests whether the argument was supplied and instantiate passesinclude_paths = NULL, which theNULLsentinel cannot distinguish from omission. That is the intended consequence of choosing the sentinel overmissing(), and here it pays. A user who passes a non-NULLinclude_pathsbreaks at the candidate, for an argument that does nothing on that branch today beyond changing what$include_paths()reports. The change is a no-op against current cmdstanr, so it costs nothing to send early, and nothing forces it early now that the stages land on thev1.0branch rather than master. The other branch,cmdstan_model(stan_file = ...)when the executable is missing, uses both legitimately and keeps them -
brms:.parse_model_cmdstanr()moves onto the standalone family, which removes lines rather than adding them..compile_model_cmdstanr()needs no change. The call it replaces builds a throwaway object withcmdstan_model(compile = FALSE)solely for$check_syntax()and$code()(brms/R/backends.R:23-34), which is the case the standalone family was designed for, so the replacement removes lines rather than adding them..compile_model_cmdstanr()already supplies options on every construction, which is what Design note: v1.0 compilation state and C++ options #1254 §2 asks of every caller. brms setscpp_options$stan_threadsonly when threading is requested, so its users meet the threading policy as a rebuild when they toggle it off
brms and instantiate are on CRAN and have to work against both the old and new cmdstanr, so a version guard rather than a clean switch. rethinking is distributed from GitHub with cmdstanr in Depends, so it can require the new version outright.
How a removed argument reaches users who never call cmdstanr. brm(stan_model_args = list(...)) becomes compile_args and reaches do_call(cmdstanr::cmdstan_model, args) inside .compile_model_cmdstanr(), and instantiate::stan_compile_model() and stan_package_model() both end their signature with ... and forward it verbatim, so the packages themselves need no change while their users do. That is the measurement behind the compile_model_methods NEWS entry above. Neither package names either argument anywhere, measured across both installed trees; rethinking cannot be reached this way at all, because its four call sites name every argument and forward no dots. brms already uses the replacement: .expose_functions_cmdstanr() calls stanmodel$expose_functions(), and expose_functions.brmsfit tests "expose_functions" %in% names(stanmodel), so a downstream package inspects the R6 object for that method by name (#1254 §5).
No survey proves there is no further caller, and instantiate reaches us through eval(parse(text = paste0("cmdstanr::", name))), so no static check will find a break in it, ours or theirs. Run all three packages' test suites against the candidate rather than trusting a search.
Formatting and linting
The formatter and the linter are scheduled around the candidate, and they go to different places.
Air's one-time whole-repo format (#1153) is the last change before the release candidate, and it is optional. It goes before the tag rather than after because a candidate that is not the source we ship is not a candidate: the tag exists so people test what becomes 1.0, and a whole-repo automated rewrite afterwards leaves the tested tree and the released tree differing by a diff nobody reviewed against the release. Optionality does not answer that objection. It decides whether Air runs, not when, and the decision can be taken when the NEWS reconciliation lands.
Three arguments that look like they belong here do not. Branch conflicts are real but choose no slot: eight open pull requests touch R/ today, three of them untouched since 2025, so the cost is whatever happens to be open when Air runs, which is much the same whenever that is. Whitespace-only determinism makes the change cheap in any slot. And the worry that a reformatting diff on top of the API removal would hide what broke does not survive Air being its own pull request, reviewed as whitespace-only with the suite green, so nothing lands on top of anything.
One check when it runs. Air reformats #' lines like any others, so a reflow that moves a roxygen tag regenerates .Rd and NAMESPACE differently and R CMD check will not notice. Re-run roxygen afterwards and confirm the generated files are unchanged.
Its PR-review action is a separate thing: additive, conflicting with nothing, and most useful during the stages, since stages 2 to 4 write a good deal of new code that would otherwise be formatted after the fact. Check first whether it comments on changed lines or on whole files; if the latter, it waits for the format.
Jarl (#1172) does not travel with it. Adopting the linter is additive, but acting on its findings is semantic editing, and that must not land after the candidate, or 1.0 would ship code in a form nobody tested. Those findings are ordinary reviewed changes, taken whenever, not a sweep.
Neither is folded into stage 4's own pull requests, where a reformatting or linting diff carried alongside the API removal would leave a downstream maintainer unable to see what broke. Air's slot after the NEWS reconciliation satisfies that on its own: the removal is reviewed and merged by then, and Air's diff sits beside that work rather than inside it.
Independent, can land any time
- cmdstan_model(exe_file = ) surfaces a raw processx error when the executable cannot be run #1246: done in v1.0 stage 4: Build at construction and guard what runs the executable #1273 (adoption) and Name the executable when it can't be run #1280 (the four launch sites); closes when
v1.0merges to master. Widened by the design review: the friendly error belongs at every site that launches the model binary (Launch the model executable with the TBB its build resolved #1261 enumerates the four), not only the two that adopt one. Record-aware adoption (stage 4) stops launching a hash-matched binary, so an executable that cannot run reaches$sample()before anything has said so, and today that is a rawprocessxerror naming a relative path. Not a rebuild trigger (Design note: v1.0 compilation state and C++ options #1254 §6, "An executable that will not launch is an error, not a rebuild trigger"), so this issue is the whole remedy. Still independent: the constructor half can land first, and the run-site half is reachable today (measured: chmod the execute bit off a built model and$sample()gives the raw error) but becomes the usual path once stage 4 stops launching -
cmdstan_version_compare()conflates no version with old version #1260: done in Make cmdstan_version_compare() refuse a missing version #1284, closes whenv1.0merges to master.cmdstan_version_compare()conflates "no version" with "older version". Defence in depth rather than a fix for anything above: stage 4's adoption invariant is what stops a bad version reaching a model, and this stops the comparison answering a question it was not asked. Kept out of the design PR becauseR/zzz.R:42calls it from.onAttach(), so the blast radius is package loading. Two things worth adding when someone picks it up: a malformed non-empty string never reaches the-1at all (both".."and"garbage"error insideutils::compareVersion()withmissing value where TRUE/FALSE needed,"garbage"emittingNAs introduced by coercionfirst), andtests/testthat/test-path.R:262-265covers only valid versions, so the sentinel is untested. Design note: v1.0 compilation state and C++ options #1254 §10 records the instance and can call it fixed afterwards - Compiled model methods and
expose_functionsfail to load when RcppParallel's TBB is already in the session #1270: model methods and exposed functions fail to load when RcppParallel's TBB is already in the session. Detect it and error first; then try building them against RcppParallel's TBB. Chosen for v1.0 on 2026-09-23 with the two below, each its own pull request againstv1.0, before the voice pass - Turn off "Compiling additional model methods..." message when calling
init_model_methods#914: done in Add a quiet argument to init_model_methods() and expose_functions() #1285, closes whenv1.0merges to master. A way to silence the "Compiling additional model methods..." message - NA dimensions for complex and tuple types #925:
NAdimensions and dropped draws for tuple and complex types, as a fresh fix rather than PR fix: handle Stan tuple and complex types #1174 - $cmdstan_version() reports the installed CmdStan, not the version that built the executable #1249: closed by v1.0 stage 4: Build at construction and guard what runs the executable #1273, where
$cmdstan_version()reads the record'sbuilder. It was best landed before stage 4's adoption item, which otherwise inherits it. Fixable today from theSTAN_VERSIONthatmodel_compile_info()already returns andR/cpp_opts.R:81discards; the record'sbuilderrefines it later - Launch the model executable with the TBB its build resolved #1261: done in Launch the model executable with the TBB it was built against #1281, closes when that merges into
v1.0. Launch the model executable with the TBB its build resolved rather than the selected installation's. Gated only on stage 3 having writtentbb_dir, so it lands any time after that. No format change, and no verdict turns on it (Design note: v1.0 compilation state and C++ options #1254 §6, "Which TBB the executable is launched with is a launch-side rule, not an assessment one"). The helper, the four launch sites and the tests are in the issue
Also
- exe_file_ conflates the installed executable with the planned build destination #1253: closed by v1.0 stage 4: Build at construction and guard what runs the executable #1273;
$exe_file()is read-only and set only from a build that happened or an executable that was adopted.exe_file_conflated the installed executable with the planned destination. The second meaning has no consumer left once$compile()goes, so the field collapses rather than splitting, andcompile_impl()returning a list instead of assigning object state is what makes that so. One thing to check when that function is reviewed: the installed path is set from a build that happened, never from one that was planned. Today$compile()assignsprivate$exe_file_outside its ownif (!dry_run)block, with a comment saying the field also describes dry runs (R/model.R:948-950), which is the line the new code must not reproduce. Closes with that review. Splitting adoption out ofinitialize()(stage 4) is the other half of the same conflation - Use air for formatting #1153: Air's one-time whole-repo format is the last change before the release candidate, and optional. Before the tag rather than after it, because a whole-repo rewrite afterwards leaves the tested tree and the released tree differing by a diff nobody reviewed against the release. Branch conflicts choose no slot: eight open pull requests touch
R/today, three untouched since 2025, so the cost is whatever is open whenever Air runs. Nor does the reformatting-hides-the-break worry, since Air is its own pull request reviewed as whitespace-only with the suite green. When it runs, re-run roxygen afterwards and confirm.RdandNAMESPACEare unchanged, since Air reflows#'lines and R CMD check would not notice. Its PR-review action is separate and can land early, so the new code in stages 2–4 is formatted as it is written rather than after the fact - Lint with Jarl #1172: Jarl's findings are semantic edits, so they land as ordinary reviewed changes and never after the release candidate, which would ship 1.0 in a form nobody tested
- Neither is folded into stage 4's own pull requests, where a reformatting or linting diff carried alongside the API removal would hide what broke. Air's slot after the NEWS reconciliation satisfies that on its own: the removal is merged by then, so Air's diff sits beside that work rather than inside it
Activity
State one rule for C++ include resolution, and give #1258 the work list
Split precedence between this note and the issues by subject
Stop writing down numbers only the merging pull request knows
Settle the public result's class and nested field names
Point three restated rules at their owners
Add the v1.0 compilation-state design note (#1254)
Validate cpp_options on entry and make FALSE disable an option
Reject stanc flags that cmdstanr sets from its own arguments
Fix three test assumptions the Windows and WSL runners broke
Reject include paths in make/local STANCFLAGS and let the call's flag…
Reject build arguments for a model created from an executable alone
Rewrite the threads test that pinned FALSE enabling threading
Bump the development version and add the NEWS for Stage 1
Fix the review findings on the STANCFLAGS rule file and the cpp_optio…
71 remaining items
Match any minute stamp in the saved output file names
Import one function from each dependency with a version floor
Make the leave-nothing-behind test run finalizers first
Thank the contributors in the 1.0 release notes
Say when the standalone functions C++ is generated and why a fit keep…
Say how to keep compiled Stan functions across R sessions
Add a NEWS item for keeping compiled functions across sessions
Turn a `make/local` switch off with an empty value and fix the build …
Say what a `FALSE` stanc option does and scope the design doc's cache…
Spell a `TRUE` C++ option as `true`, the value CmdStan compares against
Bump the version to 0.99.0 for the 1.0 release candidate
Move vctrs to Suggests and drop fs
Fix the line numbers $print() pads for programs of 100 lines or more
jgabry commentedon Oct 12, 2026
Every item here is now on the
v1.0branch, so closing this. What's left before the release candidate is the Air format (#1153, PR #1199) and the release candidate tag.