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 master the day a break lands, instead of waiting for the candidate tag. 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 — 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.
Named cpp_options entries are normalized to their make spelling (uppercase) once, on entry to the build call, ahead of validation. cpp_options_to_compile_flags() (R/cpp_opts.R:129) 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 deleted, and its removal is sequenced with this item: 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 adopted-executable check silently returns TRUE for everything — every model adopted through cmdstan_model(exe_file = ), not a corner case. Deleted rather than re-keyed, because the rejection item below makes supplied build configuration an error when there is no stan_file, so cpp_options beside exe_file = never reaches it; the question it asks is already asked by assert_valid_threads() and assert_valid_opencl() at each sampling entry point, against what the binary reports. Its tests go with it (test-cpp_opts.R:131-193). 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 deliberately 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 =. 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" — and 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 flag, not a parse. 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. Breaking, so it needs a NEWS entry. Must land before stage 3b, whose decision table encodes the rule this makes sound
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. 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 — it is the flag user_header implies, 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
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: force_recompile'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. Prefer a NULL sentinel to missing() — resolve the option inside the body after the check — so omission survives ... forwarding. 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. This is what deletes exe_info_reflects_cpp_options(), so it lands with or before the canonicalization item above. Breaking, needs a NEWS entry
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
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
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 — precisely 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
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, .<model-name>.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 that participate in 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.
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. Five of the fourteen rows fail that check as originally staged, addressed by the two prerequisite items below: known_untracked_dependencies, plus all four option rows — cpp_options_supplied, cpp_options_injected, stanc_options_supplied, 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 — include_paths, user_header, the dependencies fields, artifact, builder, format_version — are computable today
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 has a fifth site and it is worse::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 an injected option merged at flag construction; resolved_header$spelling dies with :709, its only consumer (Design note: v1.0 compilation state and C++ options #1254 §3). 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.model_name, the effective --name stanc receives, as a field of its own rather than a member of stanc_options_injected (Design note: v1.0 compilation state and C++ options #1254 §4). 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, and check_csv_metadata_matches() (:948-951) then rejects runs from either side of the rename as "not generated with the same model". Record the effective value including the _model suffix, since that is what CmdStan stamps. The comparison itself belongs to stage 3b's decision table. No NEWS entry of its own — 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
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, wherever the executable goes the record goes with it — and 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. Say explicitly that instantiate is not one of these cases, since it compiles on the user's machine at install time and the record is written beside a binary that was never in git or in a tarball, or package authors will engineer around a problem they do not have. 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
The rebuild assessment as a pure function with its full decision table, tested against stage 2's fixtures, called by nothing
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, 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
Depends only on stage 2, and can be worked in parallel with stage 3. It takes a record and a request and returns a verdict; 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 actually 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. 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 — the merge is then merely 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. 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 — see 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: a usable record carries a parseable builder version, and the fallback <exe> info reports complete version fields. Syntactic only — 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, corrupt, hash mismatch, unreadable format_version): fall back to <exe> info, report unavailable provenance together with the reported_features the binary supplies, and leave $cpp_options() empty — never an invented request. Both cases: silent construction, fitting permitted, never an 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
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 — 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 twenty-seven non-removed rows, asserting $compile()'s absence separately: measured today the surface is 27 methods and one field, §3 adds $user_header() and §8 removes $compile(), so it is 27 at 1.0 while the table has 28 rows, being the union of both — a test written against the table's row count fails at 1.0 against its own table. Then exercise every member, not one per class: a representative passing says nothing about the other nine 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 the ten 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: 10 guarded methods called bare and asserted to raise it; 16 non-guarded methods 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. That is 10 + 16 + 1 = 27 methods plus one field, against 27 non-removed rows. Give the staleness error a condition class in this stage, because sixteen negative assertions matched on message text are sixteen tests that pass forever the moment the message is reworded. $clone() is additionally asserted not to error, and $expose_functions() needs a deliberate skip where Rcpp exposure is unavailable — 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
_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). $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
Launch executables with their builder's TBB rather than the session's (Design note: v1.0 compilation state and C++ options #1254 §6). tbb_path() already takes dir and R/install.R:485 already calls it that way, while every runtime site takes it bare (R/run.R:336, :422, :660, :782), so on Windows cmdstan_path() supplies the TBB whatever built the binary. Not adoption-specific: build a model, set_cmdstan_path(), sample, and a 2.39 binary runs against 2.40's TBB in released cmdstanr, with no record involved — and instantiate::stan_package_model() reaches that state by design, since it sets the path, constructs the object and restores the previous path on.exit. Fall back to cmdstan_path() when the recorded builder's TBB directory is absent, because a missing directory on PATH is worse than a present wrong one, and because a user upgrading CmdStan after installing a Stan package is instantiate's likeliest drift. A missing builder is reported through stan_build_info() and is never fatal at adoption: a model built against a system TBB through tbb_lib/tbb_inc has an rpath outside CmdStan and still runs. Convert a loader failure into an error naming the recorded installation, with reinstalling it or rebuilding from source as the remedies. Tests: an adopted record whose builder differs from the selected CmdStan; one whose builder path is absent; and the set_cmdstan_path()-between-compile-and-sample case, which needs no record at all
Document the dependencies cmdstanr does not track #1257 — the user-facing documentation of what we do not track. The detection, the field and the write-time note moved to stage 3; the stan_build_info() display is stage 5
Source-only operations always pass --allow-undefined; only the build entry points derive it 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 — $format(), $variables() and $check_syntax() all reach 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. Only the two build entry points need it — 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. Increment format_version when this lands. Injected options are recorded and never compared, so without a bump an executable built in stage 3 keeps matching its record indefinitely and goes on naming a deleted tempfile in every exception — the bug this item exists to fix, made permanent for anyone who ran master between the two stages. Design note: v1.0 compilation state and C++ options #1254 §4, "format_version versions the build interpretation contract, not the JSON shape", states the rule in general (a change in build semantics obliges a bump) and this is its first live instance; one bump covers the whole stage 3 → stage 4 window rather than one per change. Two tests: a source-backed stage-3-format record rebuilds exactly once, and an executable-only adoption instead follows §7 — no rebuild, provenance unavailable, which is what Design note: v1.0 compilation state and C++ options #1254 §7, "That exception is also who pays for a format_version bump", prices. No NEWS entry for the bump itself, since the pre-bump format will never have been in a release. Do not write the resulting number into Design note: v1.0 compilation state and C++ options #1254: a release reads exactly the format it writes, which is the rule the invalidation test checks without naming a value. 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 injected options are never compared, 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
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) — 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 precisely so it can change (Design note: v1.0 compilation state and C++ options #1254 §4, "format_version versions the build interpretation contract, not the JSON shape"), 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"). artifact and format_version are both in it, the latter as a value rather than as a layout
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, 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")
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
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 provenance = "unavailable" for every input, valid records included, passes all of them
must be true
rule
exact public field names and nesting
#1254 §8, "stan_build_info() returns a public result, not the parsed record"
request and reported_features never merged
#1254 §1, "The two are never merged into one accessor"
tri-state preserved: known true, known false, unknown
a missing path errors; an unlaunchable one with no record errors rather than returning all-unknown
#1254 §8, "The executable argument has two failure modes and both are errors"
There is no "executable-only model" scenario. stan_build_info(exe) 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 — 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 actually 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 deliberately 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 — the failure §4 is built to eliminate — 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 — 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 deliberately not only removals, and that 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 — 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, "They are preserved, and they split into two cases", 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()
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 of the CmdStan installation that built them. 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
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
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 — 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. 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) — 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
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 — the same contract $code() already has
Reformatting in place now triggers a recompile, which is the part users meet. $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 rebuilds (Design note: v1.0 compilation state and C++ options #1254 §5, "The snapshot must be captured eagerly, or it is not a snapshot"). 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 (.<model>.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 now name the real source file instead of a deleted tempfile copy
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 — 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, and rethinking is how most people first meet cmdstanr.
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
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
instantiate, early and separately — drop compile and include_paths from the exe_file call only, ahead of stage 1 rather than at the candidate. 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 deliberate consequence of choosing the sentinel over missing(), and here it pays. But a user who passes a non-NULLinclude_paths breaks from stage 1 until 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 now. 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
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.
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 actually 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
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 actually 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
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 master the day a break lands, instead of waiting for the candidate tag. 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 — 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
FALSEmust disablemake/localSTANCFLAGScpp_optionsentries are normalized to theirmakespelling (uppercase) once, on entry to the build call, ahead of validation.cpp_options_to_compile_flags()(R/cpp_opts.R:129) 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 deleted, and its removal is sequenced with this item: 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 adopted-executable check silently returnsTRUEfor everything — every model adopted throughcmdstan_model(exe_file = ), not a corner case. Deleted rather than re-keyed, because the rejection item below makes supplied build configuration an error when there is nostan_file, socpp_optionsbesideexe_file =never reaches it; the question it asks is already asked byassert_valid_threads()andassert_valid_opencl()at each sampling entry point, against what the binary reports. Its tests go with it (test-cpp_opts.R:131-193). 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 deliberately 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=.warn-pedanticalone has six spellings the converter treats differently — unnamed, namedTRUE, namedFALSE, namedNA, namedNULL(which emits--warn-pedantic=) and named"yes"— and 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 flag, not a parse. 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. Breaking, so it needs a NEWS entry. Must land before stage 3b, whose decision table encodes the rule this makes sounduser_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. 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 — it is the flaguser_headerimplies, 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 nothingvalidate_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:force_recompile'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. Prefer aNULLsentinel tomissing()— resolve the option inside the body after the check — so omission survives...forwarding. 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. This is what deletesexe_info_reflects_cpp_options(), so it lands with or before the canonicalization item above. Breaking, needs a NEWS entrywarn-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
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 firstreported_featuresby presence — a key is written only when the state is known — precisely 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 recordBehaviour-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,
.<model-name>.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 that participate in 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
known_untracked_dependencies, plus all four option rows —cpp_options_supplied,cpp_options_injected,stanc_options_supplied,stanc_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 —include_paths,user_header, thedependenciesfields,artifact,builder,format_version— are computable todayR/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_optionshas a fifth site and it is worse::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 an injected option merged at flag construction;resolved_header$spellingdies with:709, its only consumer (Design note: v1.0 compilation state and C++ options #1254 §3). 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.model_name, the effective--namestanc receives, as a field of its own rather than a member ofstanc_options_injected(Design note: v1.0 compilation state and C++ options #1254 §4). 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, andcheck_csv_metadata_matches()(:948-951) then rejects runs from either side of the rename as "not generated with the same model". Record the effective value including the_modelsuffix, since that is what CmdStan stamps. The comparison itself belongs to stage 3b's decision table. No NEWS entry of its own — 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 5.gitignore"). The rule is one line — whatever ignores the executable ignores the record, wherever the executable goes the record goes with it — and 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. Say explicitly thatinstantiateis not one of these cases, since it compiles on the user's machine at install time and the record is written beside a binary that was never in git or in a tarball, or package authors will engineer around a problem they do not have. 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
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 readingDepends only on stage 2, and can be worked in parallel with stage 3. It takes a record and a request and returns a verdict; 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 actually 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
$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. The two halves have a required order and it is easy to get backwards. Validators may move toreported_featuresbefore the merge is deleted — the merge is then merely 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. 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 — see 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: a usable record carries a parseablebuilderversion, and the fallback<exe> inforeports complete version fields. Syntactic only — 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, corrupt, hash mismatch, unreadableformat_version): fall back to<exe> info, report unavailable provenance together with thereported_featuresthe binary supplies, and leave$cpp_options()empty — never an invented request. Both cases: silent construction, fitting permitted, never an 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$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 twenty-seven non-removed rows, asserting$compile()'s absence separately: measured today the surface is 27 methods and one field, §3 adds$user_header()and §8 removes$compile(), so it is 27 at 1.0 while the table has 28 rows, being the union of both — a test written against the table's row count fails at 1.0 against its own table. Then exercise every member, not one per class: a representative passing says nothing about the other nine 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 the ten 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: 10 guarded methods called bare and asserted to raise it; 16 non-guarded methods 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. That is 10 + 16 + 1 = 27 methods plus one field, against 27 non-removed rows. Give the staleness error a condition class in this stage, because sixteen negative assertions matched on message text are sixteen tests that pass forever the moment the message is reworded.$clone()is additionally asserted not to error, and$expose_functions()needs a deliberate skip where Rcpp exposure is unavailable — 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$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).$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 argumenttbb_path()already takesdirandR/install.R:485already calls it that way, while every runtime site takes it bare (R/run.R:336,:422,:660,:782), so on Windowscmdstan_path()supplies the TBB whatever built the binary. Not adoption-specific: build a model,set_cmdstan_path(), sample, and a 2.39 binary runs against 2.40's TBB in released cmdstanr, with no record involved — andinstantiate::stan_package_model()reaches that state by design, since it sets the path, constructs the object and restores the previous pathon.exit. Fall back tocmdstan_path()when the recorded builder's TBB directory is absent, because a missing directory onPATHis worse than a present wrong one, and because a user upgrading CmdStan after installing a Stan package is instantiate's likeliest drift. A missing builder is reported throughstan_build_info()and is never fatal at adoption: a model built against a system TBB throughtbb_lib/tbb_inchas an rpath outside CmdStan and still runs. Convert a loader failure into an error naming the recorded installation, with reinstalling it or rebuilding from source as the remedies. Tests: an adopted record whose builder differs from the selected CmdStan; one whose builder path is absent; and theset_cmdstan_path()-between-compile-and-sample case, which needs no record at allstan_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 —$format(),$variables()and$check_syntax()all reach 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. Only the two build entry points need it — 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. Incrementformat_versionwhen this lands. Injected options are recorded and never compared, so without a bump an executable built in stage 3 keeps matching its record indefinitely and goes on naming a deleted tempfile in every exception — the bug this item exists to fix, made permanent for anyone who ran master between the two stages. Design note: v1.0 compilation state and C++ options #1254 §4, "format_versionversions the build interpretation contract, not the JSON shape", states the rule in general (a change in build semantics obliges a bump) and this is its first live instance; one bump covers the whole stage 3 → stage 4 window rather than one per change. Two tests: a source-backed stage-3-format record rebuilds exactly once, and an executable-only adoption instead follows §7 — no rebuild, provenance unavailable, which is what Design note: v1.0 compilation state and C++ options #1254 §7, "That exception is also who pays for aformat_versionbump", prices. No NEWS entry for the bump itself, since the pre-bump format will never have been in a release. Do not write the resulting number into Design note: v1.0 compilation state and C++ options #1254: a release reads exactly the format it writes, which is the rule the invalidation test checks without naming a value. 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 injected options are never compared, 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 silenceWith 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)— 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 precisely so it can change (Design note: v1.0 compilation state and C++ options #1254 §4, "format_versionversions the build interpretation contract, not the JSON shape"), 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").artifactandformat_versionare both in it, the latter as a value rather than as a layoutprovenanceaslist(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, 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 (Design note: v1.0 compilation state and C++ options #1254 §8, "A readable record whose hash does not match is withheld in full")reported_featuresthe binary supplies, never an empty result that reads as "nothing was configured" (Design note: v1.0 compilation state and C++ options #1254 §7, "Executable without a usable record")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 recordexpect_*inside those scenarios rather than as new files. The scenarios name inputs and pin nothing on their own: a reader returningprovenance = "unavailable"for every input, valid records included, passes all of themstan_build_info()returns a public result, not the parsed record"requestandreported_featuresnever mergedknown_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 readunsupported_formatoffformat_versionunsupported_formatis not evidence that cmdstanr is old"There is no "executable-only model" scenario.
stan_build_info(exe)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 — 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 actually 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 deliberately 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 — the failure §4 is built to eliminate — 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 — 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 deliberately not only removals, and that 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 — 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, "They are preserved, and they split into two cases", treats that as a first-class pattern);mod$check_syntax()tocheck_syntax_stan_file();mod$format()toformat_stan_file();mod$variables()tostan_variables()compile_model_methodsandcompile_standaloneare removed, withfit$init_model_methods()and$expose_functions()as the migration. Keep it separate from the$compile()entry above rather than folding it in: that entry is about deferred compilation, while these two are post-build actions that never configured anything. Say plainly that both were silently ignored whenever the executable was already up to date, since a user who relied on them and never hit a rebuild will otherwise read this as losing something that worked. The entry also has to reach users who never named either argument, becausebrm(stan_model_args = )and instantiate's...forward themset_cmdstan_path()loaded another CmdStan's TBB$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 staysexe_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. 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) —list(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 adoptionforce_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 — the same 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 rebuilds (Design note: v1.0 compilation state and C++ options #1254 §5, "The snapshot must be captured eagerly, or it is not a snapshot"). 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 silence.<model>.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 now name the real source file instead of a deleted tempfile copyNEWS.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. 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 — 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, andrethinkingis how most people first meet cmdstanr.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 itinstantiate— 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 samplesinstantiate, early and separately — dropcompileandinclude_pathsfrom theexe_filecall only, ahead of stage 1 rather than at the candidate.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 deliberate consequence of choosing the sentinel overmissing(), and here it pays. But a user who passes a non-NULLinclude_pathsbreaks from stage 1 until 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 now. 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 changebrmsandinstantiateare 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.Independent, can land any time
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 actually 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 laterAlso
exe_file_conflates the installed executable with the planned destination. Closes if and only ifcompile_impl()(Remove deferred compilation and $compile(); add standalone file operations #1256) leaves no path that configures a destination without building. The public half goes once deferred compilation is removed, but exe_file_ conflates the installed executable with the planned build destination #1253 records that the internal dry-run path keeps the latent bug alive, so this is decided when Remove deferred compilation and $compile(); add standalone file operations #1256 lands rather than assumed now. 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