You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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 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. 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.
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.
Quoted values in make/local STANCFLAGS are split on whitespace and break direct stanc calls #1232: quoted values in make/local STANCFLAGS. Its include-path example is now a rejection test; the quoting fix gets its own fixture, --filename-in-msg='/my dir/model.stan' in make/local reaching direct stanc as one argument, which stage 4 reuses to assert the drop under the flag-precedence rule leaves no stray element
Named cpp_options entries are normalized to their make spelling (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, so list(USER_HEADER = h), list(user_header = h) and list(User_Header = h) are one variable to make and three values to R, and the codebase reconciles that three times in two directions today: toupper() outbound, tolower() in parsed_cpp_options() (:100) inbound, and tolower() again in the dormant validate_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 retires parsed_cpp_options()'s exclusion list (:101), for a different reason on each entry: user_header cannot reach a supplied list because the named spelling is rejected below, while a supplied STAN_VERSION is an ordinary Make variable that CmdStan itself never reads (the name is one cmdstanr synthesizes at R/cpp_opts.R:68 from the three stan_version_* fields <exe> info prints, and CMDSTAN_VERSION is CmdStan's own version variable), but a user's make/local can read $(STAN_VERSION) off the make command line and change CXXFLAGS with it, so it is recorded and compared like FOO, 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 against tolower(names(exe_info)), so the moment the parser stops folding case that intersection is empty for every option and the check silently returns TRUE. 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 across test-model-recompile-logic.R, test-model-generate_quantities.R and test-cpp_opts.R expect its warning. Drop the fold so both sides carry the make spelling. 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") and exe_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 lists stan_cpp_optims among the flags reported in exe info, and write_stan_flags.hpp reports 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_options are left alone: stanc is case-sensitive and rejects --Warn-Pedantic with 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 not include-paths and emits --include-paths=/b, so it needs a test of its own, an error naming include_paths. The check goes in assert_valid_stanc_options() (R/model.R:2562) beside the leading-hyphen check. warn-pedantic alone has six spellings the converter treats differently: unnamed, named TRUE, named FALSE, named NA, named NULL (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/local is excluded: it is text in CmdStan's own file rather than a list entry, and keeps the substring test noted below
include_paths becomes the only channel into stanc's search path. --include-paths supplied through stanc_options (matched on occurrence, per the rule above), through make/local's STANCFLAGS, or through STANCFLAGS in cpp_options reaches the build (R/model.R:837, :839, and a make command-line assignment that cmdstanr's STANCFLAGS += appends to rather than replaces) but not the stanc --info call 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, and STANCFLAGS in cpp_options is rejected outright, since stanc_options is the channel for stanc flags and a raw make-variable passthrough only duplicates it; in the make/local case detection is a substring test on the value Make resolves, not a parse of the file, matching --include-paths or 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, "The STANCFLAGS check reads what Make resolved, not what make/local says, and runs at build time only"). The two rejections differ in scope on purpose: cpp_options is a cmdstanr argument so the whole STANCFLAGS variable goes, while make/local is CmdStan's own config file (make/local.example:20 suggests STANCFLAGS+= --warn-pedantic) so only the include-path flag is refused there. Put the cpp_options check in assert_valid_cpp_options() (Unnamed raw cpp_options assignments reach make but are invisible to everything that keys on names #1250), which cmdstan_make_local() does not call (R/install.R:324-338), so writing STANCFLAGS into make/local through the supported function keeps working. Tests for the make/local arm: STANCFLAGS += --include-paths=/b, STANCFLAGS += -I /b and STANCFLAGS += -I/b, each rejected with the error naming include_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's STANCFLAGS: drop the make/local occurrence 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.stan as two arguments; one hyphen, not two, because -fno-soa is a stanc option and must not be consumed as the value of a --warn-pedantic before it (Design note: v1.0 compilation state and C++ options #1254 §6, "A flag the call emits wins over the same flag in make/local's STANCFLAGS"). stanc's handling of a repeat varies by version, 2.37 refuses every repeat and 2.38 and 2.39 refuse valued ones, so pedantic = TRUE against make/local.example's --warn-pedantic line and list("O1") against a make/local--O1 fail the build today on 2.37. Not the include-path rejection, which stays. Tests, each with the flag in make/local and asserting stanc sees it once: pedantic = TRUE with --warn-pedantic; list("O1") with --O1; and at stage 4, the injected --filename-in-msg against a make/local value in each form, --filename-in-msg=published.stan and --filename-in-msg published.stan, the call's winning and stanc seeing no stray published.stan element; and pedantic = TRUE against --warn-pedantic -fno-soa, with -fno-soa still reaching stanc
The user_header argument becomes the only channel for the user header. cpp_options[["USER_HEADER"]] and cpp_options[["user_header"]] are rejected with an error naming it. This deletes most of resolve_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 whose previous parameter Remove deferred compilation and $compile(); add standalone file operations #1256 removes along with deferred compilation. Its supplied flag goes with them (Design note: v1.0 compilation state and C++ options #1254 §7, "Explicit NULL means 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 to previous, so with both gone user_header = NULL and an omitted user_header are the same request. initialize()'s "user_header" %in% names(args) test (R/model.R:279) goes with it, and $compile()'s missing() 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 the cpp_options spelling. Breaking, needs a NEWS entry
Reject allow-undefined in stanc_options, matched on occurrence, with the same error as the header channel above, since it is the flag user_header implies 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-opencl in stanc_options, matched on occurrence, naming cpp_options = list(stan_opencl = TRUE). It is the flag stan_opencl implies (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 emits matrix_cl members only where a GLM-family function takes data it can move to the device, and those types exist only when STAN_OPENCL is 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 embedded stancflags string, builds, and reports STAN_OPENCL=false. That is the worse outcome of the two, since nothing tells the caller their request did nothing
Reject name in stanc_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, since R/model.R:273 takes 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 supplied name reaches stanc but never touches private$model_name_, so stanc_options = list(name = "foo") leaves $model_name() answering bernoulli while every CSV the binary writes stamps foo. Letting the option write model_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 through write_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 at R/model.R:834 and :1138 unconditional; the option_name != "name" quoting exception in stanc_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 mention stanc_options at all, and rethinking's cstan() and ulam() default it to list("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
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_header and force_recompile cannot configure an artifact that will not be rebuilt, and a valid record is there to be inspected rather than overridden. include_paths and pedantic fail on the source instead, and that reason belongs in their messages: include_paths configures source resolution, needed by every stanc invocation whether or not anything compiles, while pedantic asks 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 is getOption("cmdstanr_force_recompile") (R/model.R:621), so a check written as isTRUE(force_recompile) would error for every adoption performed by anyone with that option set, including every instantiate fit 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 the cmdstanr_force_recompile option"), and that is three functions rather than two, since cmdstanr_example() resolves it in its own signature at R/example.R:62 and hands the answer to cmdstan_model(). Not missing(): a wrapper's own NULL default already makes it FALSE in the shared implementation, and the shared implementation is where the rebuild reason has to tell an explicit force_recompile = TRUE from an option set in .Rprofile months 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 as force = TRUE or cpp_opt = list() beside exe_file passes 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-pedantic in stanc_options, matched on occurrence, with an error naming pedantic = TRUE. Two channels for one setting, and here they would differ in kind rather than in spelling: pedantic = TRUE is 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 through stanc_options is supplied and compared, so it warns only when a build happens. The named FALSE has a reason of its own on top of that: it emits nothing today while pedantic = TRUE still 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)
Replace the hand-enumerated tests/testthat/resources/stan/.gitignore with 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 from ls but not from git, so git add -A commits 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_version rather 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 by cmdstan_model() in stage 4 and unavailable to stan_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 a format_version that parsed. Three fixtures, and the two after the first are the ones an implementation guided by it will get wrong: invalid JSON; a record whose format_version is present and supported but which carries one required field of the wrong type; and a record whose format_version is 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 no format_version, and that the third is unsupported_format and 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 leaves unsupported_format unreachable 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_features by presence (a key is written only when the state is known) because the obvious NA-for-unknown encoding does not survive: jsonlite writes NA as null and reads it back as NULL, so the R type is gone after one trip through a file and is.na() returns logical(0), which errors in an if. The two states stay recoverable through names(), but not through the access anyone writes: x[["k"]] is NULL either way and !isTRUE(x) is TRUE either 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 no null ever appears in a written record. Assert the validator's side of it too, since the round trip passes whatever the validator does: a record whose reported_features omits 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_features is checked for shape and never for membership"). STAN_CPP_OPTIMS moving 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)
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_supplied and stanc_options_injected, none of which is computable while injections land in the caller's list. reported_features passes 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, the dependencies fields (the user header among them), artifact, builder and format_version, are computable today; stanc_name is the effective name, which the merged list already holds, so it needs the split for ordering rather than for correctness. tbb_dir is the one row left over, and it is computable here too, from the call's own cpp_options rather 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, :693 and :835 all write into the same stanc_options variable, so by the time the writer runs the user's entries and cmdstanr's are indistinguishable. cpp_options needs no accumulator, but it does need a deletion, and it is the worse bug::709 writes the resolved user header back in under whichever spelling was used, and :941 stores that list, so $cpp_options() today reports USER_HEADER = "/abs/path/inc/mine.hpp" for a caller who passed user_header = "inc/mine.hpp" as an argument and never touched cpp_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, so cpp_options_supplied read off that variable is wrong even with no concept of injection at all. USER_HEADER= still has to reach make (make/program:41) and does so as a flag built with the others, not as a recorded cpp_options entry, since recording it would hold the header's path in request as well as in dependencies, 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$spelling dies with :709, its only consumer. With :709 gone, cpp_options holds exactly what the caller passed, which is why there is no cpp_options_injected field to build and the accumulator work below is stanc_options only. Stage 3 would then have to either put the merged list into _supplied, which silently makes every injection a compared option and makes toggling pedantic recompile, 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 _supplied and _injected are 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 --name stanc receives, as a separate compared field as well as its place in stanc_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). With name rejected from stanc_options in stage 1, the derived value is the only one there is. It rides on the item above, since R/model.R:835 is 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:873 maps the CSV header onto fit$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 and check_csv_metadata_matches() (:948-951) rejects the mixture then. The field is named stanc_name rather than model_name so that it cannot be read as a second answer to mod$model_name(), which returns the same name without the suffix. Record the value as passed, including the _model suffix R/model.R:835 appends. 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_model compiles to my_model_model, my.model_model to myx46model_model, my+model_model to myx43model_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 a my-model.stan fixture, whose record must hold the raw my-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 a stancflags line 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 through stan_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 is known_untracked_dependencies, not provenance_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's sinclude spelling. It is a fixed keyword, so it costs one alternation and no Make parsing. Test positive and negative detection for both, with sinclude among the positive make/local cases, and that the note fires on a successful write and not otherwise. Displaying the field through stan_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, the tbb_dir row). Read the call's cpp_options as make receives them, last assignment wins and FALSE is an empty one, and take the first non-empty of TBB_LIB, TBB_BIN and the installation's own lib/tbb, which is the order the makefile links in. Resolve a relative directory against the installation, where make runs. Not asked of make: a TBB_LIB or TBB_BIN from make/local, ~/.config/stan/make.local or 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's print-% 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 own lib/tbb; a build with cpp_options = list(tbb_lib =) recording that directory and not the default; a relative TBB_LIB recorded absolute; and the helper on a repeated TBB_LIB, a FALSE one with TBB_BIN beside it, and a FALSE one 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.json beside whatever already excludes the binary in .gitignore. Do the same in .Rbuildignore, which is the easier miss: R CMD build excludes 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 is vignettes/cmdstanr-internals.Rmd in 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 builder path 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: builder is 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 on builder differing, 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's observed carries 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, "--name is 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 a stanc_name row passes all of them green. Add the punctuation-only rename beside it, my-model.stan to my_model.stan, which must also rebuild even though both compile to my_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.expected carries artifact hash H1, while observed carries a binary hashing to H2 beside a record whose artifact is 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_from under exact/ and hash H, against observed holding the same hash H under approx/, 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 whose odds_impl.hpp differ, giving mean(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_name the 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-693 is where the injections happen, but the audit is the argument-to-option mapping rather than the mutation sites. pedantic = TRUE becoming --warn-pedantic is 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") and list("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 with list("O0") must rebuild, and fails against an implementation that never compares stanc options at all. list("O1", "warn-uninitialized") against a record built with list("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 out warn-pedantic, allow-undefined, use-opencl, name and include-paths as 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 --O0 and --O0 --O1 both generate the code plain --O0 does and differ only in the stancflags string stanc embeds. The fixture pair is identical for a plainer reason, --warn-uninitialized changing 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
§1's request/report split goes live: $cpp_options() reports only what the caller asked for, and the runtime validators read reported_features instead. Delete merge_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() to cpp_options_supplied, and carry reported_features as the tri-state §1 defines (known enabled, known disabled, unknown), with absence never collapsed to disabled. Then move assert_valid_threads() (R/cpp_opts.R:282) and assert_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 without threads_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) write STAN_NUM_THREADS into the R session when threads are supplied, CmdStan reads it as the default when num_threads is absent, so an omitted call inherits the previous call's count. Scope the variable to the child through processx::process$new(env = ), which wsl_compatible_process_new() forwards, set when supplied and absent otherwise, and move the WSLENV export 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"). Not num_threads= on the command line: CmdStan refuses to start when it disagrees with a set STAN_NUM_THREADS. Tests: a consecutive-call test, threads_per_chain = 4 then an omitted call, asserting metadata()$threads_per_chain is 1 on the second; the thirteen Sys.getenv("STAN_NUM_THREADS") assertions in test-threads.R become 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 to reported_features before 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_THREADS inherited from make/local is not in cpp_options_supplied, so a threaded binary reads as unthreaded and threads_per_chain is refused again. The test matrix is that regression, with STAN_THREADS=true in make/local and nothing passed to cpp_options: $cpp_options() empty, reported_features reporting threading enabled, threads_per_chain = 4 sampling. Assert reported_features and the validator, not stan_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() renders reported_features, so going through it would let a renderer bug fail a test whose subject is make/local inheritance. 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 = 1 on 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, then threads_per_chain = 2 asserted 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 to FALSE in 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> info returning a valid version with STAN_THREADS absent must construct with threading unknown and error on threads_per_chain = 2 the 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 as FALSE on the live path. The fixture is nearly free: stage 4 already builds a fabricated hash-bound record for the builder test. 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_chain against 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 why exe_file means 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 removing compile, 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: hydrate request, reported_features and builder from it and do not launch the executable: the hash proves the binary is the one whose features were recorded, so model_compile_info() is not called at all. Measured on a 3 MB binary that is ~2 ms against ~24 ms, and instantiate pays it on every fit rather than once at install (§9). $cpp_options() returns the recorded cpp_options_supplied and $user_header() the recorded path, which is §1's rule sourced from the record instead of the call. $cmdstan_version() comes from builder; that is $cmdstan_version() reports the installed CmdStan, not the version that built the executable #1249, which can land independently first off the STAN_VERSIONmodel_compile_info() already returns and R/cpp_opts.R:81 discards, but adoption is the one path where leaving it unfixed stays wrong forever, since everywhere else builder is 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 unparseable builder version 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> info must 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 it model_compile_info() synthesises ".." from three absent fields (R/cpp_opts.R:68), which passes every guard cmdstan_version_compare() has, so construction succeeds and the failure surfaces later inside a version gate as a TRUE/FALSE complaint. Test an info result missing the version fields and one printing a malformed value. Unusable record (missing, unreadable, hash mismatch, unsupported format, or an unparseable builder version, 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 the reported_features the 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 unused version parameter from model_compile_info() (R/cpp_opts.R:52) while rewriting its callers: three call sites pass self$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 with builder at 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, since run_info_cli() passes error_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 raw processx_exec error, 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> info yields a syntactically valid version, where today construction succeeds and R/model.R:318 attributes 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 builder guarantees it fires"). Adopt an executable whose valid record names a builder other 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 on builder, which is what shows the difference is the missing source rather than the engine. The fabricated-builder record 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 with CmdStanModel$public_methods and $public_fields and 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); the functions public 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 expected from 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 what tar -x, cp -p and 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 to file.mtime(), as R/model.R:732-733 does 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
_pkgdown.yml entries for the standalone family (compile_stan_file, format_stan_file, check_syntax_stan_file, stan_variables) and removal of any topic the same pull request deletes. The reference index is an explicit contents: list and pkgdown errors on topics missing from it, so .github/workflows/pkgdown.yaml fails on CI otherwise. Stage 5 carries the same item for stan_build_info
Remove compile_model_methods and compile_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 run expose_stan_functions() and expose_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 in compile_impl()'s signature. Both are already dropped in silence whenever the executable is current, because $compile() returns at :804 and the exposures sit past it, so the same call populates functions or 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:108 asserts 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 same get_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:108 flips 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 when function_env$existing_exe is TRUE (R/utils.R:1217), and :267, :299 and :786 together leave it TRUE for a source-backed model whose executable is up to date, so cmdstan_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. Make existing_exe mean "this model has no source", and generate the hpp on demand from the registered source. 16 test references to update, across test-model-expose-functions.R, test-model-methods.R and test-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 to cmdstan_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_code off 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 once compile = FALSE and public dry_run go, 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 the existing_exe to has_generated_cpp rename 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, no compile = FALSE involved. That buys earliness rather than correctness, since this stage replaces it with generating the hpp on demand
The selected-installation check, immediately before make or 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"). make runs in the selected installation (R/model.R:862-866) and so does stanc, which stanc_cmd() names relatively as bin/stanc (R/utils.R:123-129): the build, the re-resolution an assessment needs whenever the selection is also the recorded builder, the construction-time stanc --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() and fit$cmdstan_diagnose() run bin/stansummary and bin/diagnose with the selected installation as their working directory (run_cmdstan_tool(), R/run.R:316) and build them on demand with a make in that same tree (check_target_exe(), :414). Both live on CmdStanFit, so a check written into the build path, or into CmdStanModel$initialize(), never reaches them. Each must fail with a message naming the installation rather than reaching cannot 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) and cmdstan_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() and check_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, while stan_variables() on the same file errors (Design note: v1.0 compilation state and C++ options #1254 §6, "$variables() is not on that list and stan_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 same system error 2. And an executable-only model with a valid record and an intact recorded builder still constructs, still answers stan_build_info(), and still samples with that same selected installation removed, since it neither builds nor re-resolves and on Windows takes its TBB from the recorded tbb_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 of initialize(), passes the first three and refuses the packaged models Design note: v1.0 compilation state and C++ options #1254 §7 exists to admit
Source-only operations always pass --allow-undefined; only the build entry points derive it from user_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's if (private$using_user_header_) conditionals become unconditional and the dependency on using_user_header_ leaves all three. Accepted cost, documented rather than filed later: check_syntax_stan_file() passes where compile_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 #include and no include_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 via self$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 an include_paths argument (Design note: v1.0 compilation state and C++ options #1254 §8); without it format_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 read self$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-824 compiles a tempfile() 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 in stanc_options wins untouched, and either wins over a value in make/local's STANCFLAGS under 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, and cmdstan_min_version() is 2.35 (R/path.R:145), so no version guard is needed. No format_version bump, so an executable built in stage 3 goes on naming the tempfile until something else rebuilds it. That is the general rule rather than a concession made here: cmdstanr changing what it injects never rebuilds a binary that already exists, and the caller asks for the new behaviour with force_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 a format_version is not readable"). Pin the rule with a stage 3b fixture rather than leaving it implicit: a record whose stanc_options_injected lacks --filename-in-msg, against a current request that injects it, must not rebuild. Needs its own NEWS entry and test for the fix
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 the stanc --info call made there for include re-resolution already returns the variable information in the same response
pedantic = TRUE must run the check even when nothing rebuilds, which makes it behaviourally significant on the no-op path and means compile_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; construct again with pedantic = TRUE; nothing rebuilds and the stanc check still runs (the pedantic item 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 reports metadata()$threads_per_chain of 1 and the session's STAN_NUM_THREADS is 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/local with STANCFLAGS += --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 in make/local's STANCFLAGS")
Unthreaded executable on disk; construct with cpp_options = list(stan_threads = TRUE) rebuilds; $sample(threads_per_chain = 2) reports 2; construct again with no cpp_options rebuilds 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.
Stage 5: public build-record inspection
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. Not jsonlite::fromJSON() output. The on-disk schema is private and format_version exists 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 artifact hash, every hash under dependencies, stanc_options_injected, stanc_name and tbb_dir stay 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"), with S3method(print, stan_build_info) reaching NAMESPACE through roxygen. This is the package's first S3 class of its own: today's fifteen S3method lines are all as_draws and process_init dispatching on R6 class names, and there is no print method anywhere in R/
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 dependencies and 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, under dependencies"); assert it against a model built with a real user header, since a no-header fixture returns user_header = NULL and passes whatever the implementation does when there is one. And dependencies covers make/local, which is a dependency with its own trigger rather than part of builder, and is NULL when the installation had none (Design note: v1.0 compilation state and C++ options #1254 §8, "make_local is NULL when the installation had none")
known_untracked_dependencies entries are list(kind, detected_in), kind from a fixed pair (make_local_include, user_header_include) and detected_in the 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, a make/local carrying an -include line and a user header carrying a quoted #include, and the assertion is on detected_in as well as kind, since a fixture with one gap passes an implementation that hardcodes the other file. One entry per distinct (kind, detected_in) pair, ordered by kind then detected_in (Design note: v1.0 compilation state and C++ options #1254 §8, "One entry per distinct (kind, detected_in) pair, ordered by kind then detected_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 with user_header_include first and require make_local_include first coming out. Each fixture above produces a single kind, so none of them can fail if the order is wrong
reported_features with fixed names and NA for unknown, which is not the record's presence encoding (Design note: v1.0 compilation state and C++ options #1254 §8, "reported_features has fixed names, and unknown is NA"). Nothing in the result round-trips through a file, so NA keeps its type. It buys no louder failure than the record's encoding does, since isTRUE() reads NA and a missing key alike as FALSE and if () errors on both. What it buys is a state is.na() can ask about, where a missing member answers logical(0), and a fixed names(reported_features) a test can hold. The names are the four booleans <exe> info prints plus stan_version, always all present, and the two types differ: the flags are logical, TRUE, FALSE or NA, while stan_version is a character scalar or NA_character_. Assert the type, not only the value, or an implementation returning "TRUE" passes
provenance as list(status, reason) with a machine-readable reason enum (record_missing, record_unreadable, artifact_mismatch, unsupported_format), which is §7's four forms made machine-readable rather than a new taxonomy. available requires reason = NULL, unavailable exactly one reason, both names present either way so names(provenance) is a fixed pair, and no free-form message is stored: the printer derives prose from the enum, including the direction for unsupported_format, which runs both ways (Design note: v1.0 compilation state and C++ options #1254 §8, "provenance carries why, not only whether")
artifact_mismatch withholds request, dependencies and builder even 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_features still 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_version is present for unsupported_format and absent for the other three reasons (Design note: v1.0 compilation state and C++ options #1254 §8, "format_version is public only under unsupported_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 and print.stan_build_info(x) receives nothing but x. The absence under record_unreadable is whole-record withholding rather than a claim about what could be parsed: an unreadable record reports none of its contents, including a format_version that 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 no format_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 reports unsupported_format and 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")
builder is 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 is exists = FALSE, which is a different thing
The provenance state, including the unusable-record path: unavailable provenance returned together with the reported_features the 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")
request and reported_features reported as §1 separates them, never merged
Unavailable information distinguished from a valid empty value, throughout the result. known_untracked_dependencies empty 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 is known_untracked_dependencies, not provenance_complete"), stated there as a property of one record field and applying here to the whole result
Each dependency with its built_from path 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, one vectorized file.exists() over the dependency paths, and the returned values are a snapshot. Same for the builder's flag
The builder installation and version, and known_untracked_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_from is normal for install-time builds, which Design note: v1.0 compilation state and C++ options #1254 §9 already requires in as many words
_pkgdown.yml entry for stan_build_info. Not documentation polish: the reference index is an explicit contents: list, pkgdown errors on topics missing from it, and .github/workflows/pkgdown.yaml runs 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 whose built_from no longer exists; a recorded builder that is absent; a non-empty known_untracked_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 artifact_mismatch, assert where the features came from rather than that the field is populated: make the record say stan_threads = TRUE, make <exe> info say false, require FALSE. 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 a TRUE belonging to some other build. For the valid-record case, assert the binary is never launched: local_mocked_bindings(run_info_cli = function(exe_file) stop(...), .package = "cmdstanr") covers both "execution fails if attempted" and "call count is zero" in one assertion, and run_info_cli() (R/cpp_opts.R:7) 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"
request 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
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 artifact_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"
builder 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 withreported_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".
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() and compile = FALSE to cmdstan_model(), or to compile_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() to check_syntax_stan_file(); mod$format() to format_stan_file(); mod$variables() to stan_variables(). The executable-only check from Stage 1 gets its abbreviated-argument tests here (force = TRUE, cpp_opt = list(), ped = TRUE beside exe_file with no stan_file, each an error), since the constructor owning the formals is what closes them
compile_model_methods and compile_standalone are removed, with fit$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, because brm(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_file and exe_file together are an error"). Both are accepted today, and exe_file there 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. Name dir as the replacement and be exact about what it does not cover: dir sets the directory while the filename comes from the .stan name, or from write_stan_file(basename = ) for generated code, so naming two builds of one program inside a single directory now takes a subdirectory. ?cmdstan_model needs rewriting either way: it currently calls exe_file an existing executable that can be supplied in addition to stan_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 assigns private$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 at test-model-compile.R:1526, which covers a guard that stays, dir still resolving onto a directory, so rewrite that test to reach the guard through dir rather than dropping it with the setter (exe_file_ conflates the installed executable with the planned build destination #1253)
Arguments supplied beside exe_file = with no stan_file that 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-paths in stanc_options or make/localSTANCFLAGS to include_paths; warn-pedantic in stanc_options, however spelled, to pedantic; allow-undefined in stanc_options, now derived from user_header; use-opencl in stanc_options to cpp_options = list(stan_opencl = TRUE); USER_HEADER and user_header in cpp_options to the user_header argument; STANCFLAGS in cpp_options to stanc_options; name in stanc_options to 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, since merge_exe_info_cpp_options() writes reported names in upper case over the request (R/cpp_opts.R:83), so list(stan_threads = TRUE) comes back as stan_threads and STAN_THREADS both. After canonicalization it is one entry, STAN_THREADS. Two things break and the second is silent: indexing the lower-case name returns NULL, while indexing the upper-case name keeps working and changes meaning, from a value the binary confirmed to one the caller asked for. Name stan_build_info() as where that meaning went. Test on ordinary construction and on record-backed adoption
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 raw processx_exec error (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, since info has printed stan_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 = TRUE as 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 .stan file changed after construction reports the built program, not the edited one, which is the contract $code() already has
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 at cmdstan_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 the cmdstan_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 = TRUE now 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: bernoulli is described by .bernoulli.cmdstanr.json, bernoulli.exe by .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
The validator change, claimed at stage 4 and previously listed nowhere: threads_per_chain against 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 that force_recompile = TRUE is 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")
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
Reconcile NEWS.md. The unreleased section carries fifteen-plus entries about $compile(), compile = FALSE and dry_run that 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.
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: drop compile = FALSE from three ulam() call sites and from cstan()'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 because threads_per_chain is 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 in ulam() (rethinking/R/ulam-function.R:1424, :1455, :1493) and one in cstan() (rethinking/R/cmdstan_support.r:32), all of the form cmdstan_model(stan_file, compile = , cpp_options = , stanc_options = ), and it never reads build state back: no $compile(), no dry_run, no exe_file (commented out at all three sites), none of the guarded accessors, everything else fit-side. In ulam() the argument is compile = filex[[3]] and filex[[3]] is hardcoded TRUE (:1398), so deleting the line is the whole fix; cstan() propagates to end users because compile is 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, since ulam() enables stan_threads and always supplies threads_per_chain, and it has no version-control exposure: tempdir() means records never reach a repository.
instantiate: adopt with cmdstan_model(exe_file = exe_file) alone, dropping both compile and include_paths from that call. A final-location source can be registered: after R moves the staged tree the .stan file 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, and builder compares the CmdStan installation path and version, so the next install_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_paths keeps its meaning on the install-time compile_stan_file() call. Also: decide the missing-executable branch; update the .gitignore template, 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 asserts stan_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 .gitignore template is the part that will not fix itself. The example package ships inst/stan/**, !inst/stan/**/*.*, inst/stan/**/*.exe, inst/stan/**/*.EXE: ignore everything, re-include anything with a dot so .stan files 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 with git check-ignore: the executable is ignored, .bernoulli.cmdstanr.json is 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.json line.
instantiate, separately and at any time: drop compile and include_paths from the exe_file call 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 passes include_paths = NULL, which the NULL sentinel cannot distinguish from omission. That is the intended consequence of choosing the sentinel over missing(), and here it pays. A user who passes a non-NULLinclude_paths breaks 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 the v1.0 branch 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 with cmdstan_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 sets cpp_options$stan_threads only 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: 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 raw processx error 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: 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 because R/zzz.R:42 calls 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 -1 at all (both ".." and "garbage" error inside utils::compareVersion() with missing value where TRUE/FALSE needed, "garbage" emitting NAs introduced by coercion first), and tests/testthat/test-path.R:262-265 covers 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
Launch the model executable with the TBB its build resolved #1261: launch the model executable with the TBB its build resolved rather than the selected installation's. Gated only on stage 3 having written tbb_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: exe_file_ conflates the installed executable with the planned destination. The second meaning has no consumer left once $compile() goes, so the field collapses rather than splitting, and compile_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() assigns private$exe_file_ outside its own if (!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 of initialize() (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 .Rd and NAMESPACE are 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
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 thev1.0branch 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 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. 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.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.9003today would move brms tocompile_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 anifagainst. instantiate carries the record into the package library without needing to do anything, and this is an assertion rather than an open question. Itsinstall.libs.Rcopies the sources intoR_PACKAGE_DIR/bin/stanand 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 realR CMD INSTALL, and the reason #1254 §9, "cmdstanr cannot repair an install-time-built model", predicts a00LOCK-…/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/localand 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 #1228Stage 1: Make-option correctness (merged to
v1.0in #1262, 7dc846e)FALSEmust disablemake/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 elementcpp_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 oursstanc_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 belowinclude_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 soundmake/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 stancuser_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 entryallow-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 stepuse-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 nothingnameinstanc_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 entryvalidate_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)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 matrixwarn-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 oneStage 2: schema and helper tests (merged to
v1.0in #1264, faa40ab)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 firstformat_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 schemareported_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 precedentBehaviour-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 asbuilt_fromfor 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-Iflags incpp_optionsbeing 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.0in #1265, 6a5572a)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 listR/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 ownrequest.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 belowknown_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 5tbb_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.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 oneStage 3b: the assessment engine, pure and unwired (merged to
v1.0in #1269, 390daa2)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 isbuilder, 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 itstanc_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 awayexpectedcarries 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 stagedependencies.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 compilerstanc_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.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 optionDepends 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.observedcarries 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_pathsis 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 switchinginclude_pathsfromv1/tov2/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
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")$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 goneinitialize()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")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$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 cannotexpectedfrom 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")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$compile(), add the standalone family_pkgdown.ymlentries for the standalone family (compile_stan_file,format_stan_file,check_syntax_stan_file,stan_variables) 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_infocompile_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 argumenthpp_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 demandmakeor 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(), 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 admitstan_build_info()display is stage 5--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 headerdirname(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--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, so an executable built in stage 3 goes on naming the tempfile until something else rebuilds it. That is the general rule rather than a concession made here: 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. Needs its own NEWS entry and test for the fix$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 responsepedantic = 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 silenceCall 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.
$sample()errors on the artifact hash (the replaced-executable case above)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")$format(overwrite_file = TRUE);$sample()errors; construct again rebuilds (Design note: v1.0 compilation state and C++ options #1254 §5, "reformatting forces a recompile")pedantic = TRUE; nothing rebuilds and the stanc check still runs (thepedanticitem above)$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$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")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 intendedWith 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.
Stage 5: public build-record inspection
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 breakstan_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 artifact hash, everyhashunderdependencies,stanc_options_injected,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"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/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 ofbuilder, and isNULLwhen the installation had none (Design note: v1.0 compilation state and C++ options #1254 §8, "make_localisNULLwhen the installation had none")known_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 wrongreported_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"passesprovenanceaslist(status, reason)with a machine-readable reason enum (record_missing,record_unreadable,artifact_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(provenance)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, "provenancecarries why, not only whether")artifact_mismatchwithholdsrequest,dependenciesandbuildereven 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 underrecord_unreadableis 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")builderis 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 thingreported_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")requestandreported_featuresreported as §1 separates them, never mergedknown_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 isknown_untracked_dependencies, notprovenance_complete"), stated there as a property of one record field and applying here to the whole resultbuilt_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 nowfile.exists()over the dependency paths, and the returned values are a snapshot. Same for the builder's flagbuilderinstallation and version, andknown_untracked_dependencies: populated in stage 3, displayed herestan_build_info()output and getting a healthy installation back with every dependency flaggedbuilt_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 buildreason; a dependency whosebuilt_fromno longer exists; a recorded builder that is absent; a non-emptyknown_untracked_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 binaryexpect_*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 themartifact_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:local_mocked_bindings(run_info_cli = function(exe_file) stop(...), .package = "cmdstanr")covers both "execution fails if attempted" and "call count is zero" in one assertion, andrun_info_cli()(R/cpp_opts.R:7) 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 fixturestan_build_info()returns a public result, not the parsed record"dependenciesonlydependencies"requestandreported_featuresnever mergeddetected_innaming the matched fileknown_untracked_dependencies, notprovenance_complete"known_untracked_dependenciesreaches the result and the printerstan_build_info()"built_fromgivesexists = FALSE, no warning, no unhealthy statusreason, andavailablenever carries oneprovenancecarries why, not only whether"artifact_mismatchwithholds the record fields it could have readreported_featuresfor a mismatched record come from the binary, not the recordunsupported_formatoffformat_versionunsupported_formatis not evidence that cmdstanr is old"exe_fileargument has two failure modes and both are errors"<exe> infocallsexe_fileargument has two failure modes and both are errors""stan_build_info", and the print method dispatches on itdependenciesandmake_localNA, not a missing key; every fixed name present; the four flags logical andstan_versioncharacterreported_featureshas fixed names, and unknown isNA"format_versionpresent forunsupported_formatand 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 anartifact_mismatchwhose record parsed cleanlyformat_versionis public only underunsupported_format"; #1254 §4, "The version is checked first, and on its own"make_local_includebeforeuser_header_include, whichever order they arrived in(kind, detected_in)pair, ordered bykindthendetected_in"(kind, detected_in)pair, ordered bykindthendetected_in"builderpresent for every available provenance,exists = FALSEwhen its path is goneThere 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 withexe_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 throughstan_build_info()" puts the untracked-dependency property here, §7's "Executable without a usable record" requires unavailable provenance to come back together withreported_features, §9's "The existence flag is a neutral fact, not a warning" governs how thebuilt_fromflag reads, and §9's "cmdstanr cannot repair an install-time-built model" predicts the00LOCK-…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_fromexistence 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 vectorizedfile.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".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 themcompile_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 themset_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 againstcmdstan_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$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)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 doinclude-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$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 adoptiontouch file.stanno longer forces a rebuild, since nothing compares timestamps any more, andforce_recompile = TRUEis what replaces itexe_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 unaffectedforce_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$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 insteadpedantic = 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 silencebernoulliis 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 anythingstan_build_info(), the new API (stage 5)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")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 themNEWS.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 reasonNEWS.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 candidateRelease candidate
Ships after the NEWS reconciliation and Air's format, per the order at the top, so packages built around precompiled models,
instantiatemost 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.Downstream pull requests
We open these ourselves rather than waiting to be asked.
brms,instantiateandrethinkingare the priorities. The first two are chokepoints rather than merely important packages: instantiate's own dependents callinstantiate::stan_package_model()rather than cmdstanr directly, so fixing instantiate carries its dependency tree with it.rethinkingis 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 thembrms:.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 offbrmsandinstantiateare on CRAN and have to work against both the old and new cmdstanr, so a version guard rather than a clean switch.rethinkingis distributed from GitHub with cmdstanr inDepends, so it can require the new version outright.How a removed argument reaches users who never call cmdstanr.
brm(stan_model_args = list(...))becomescompile_argsand reachesdo_call(cmdstanr::cmdstan_model, args)inside.compile_model_cmdstanr(), andinstantiate::stan_compile_model()andstan_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 thecompile_model_methodsNEWS 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()callsstanmodel$expose_functions(), andexpose_functions.brmsfittests"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.RdandNAMESPACEdifferently 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
$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 launchingcmdstan_version_compare()conflates no version with old version #1260: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 afterwardsSTAN_VERSIONthatmodel_compile_info()already returns andR/cpp_opts.R:81discards; the record'sbuilderrefines it latertbb_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 issueAlso
exe_file_conflates 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 conflationR/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