stan-dev / stan-dev/cmdstanr

v1.0 compilation state: implementation order

Open
#1,258 0 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

Dominant language
R
Stars
160
Forks
69
Avg merge
1d 19h
Merged PRs (30d)
15

Description

v1.0 compilation state: implementation order

An issue to track progress on the design in #1254 (dev-notes/compilation-state.md). The rest of this is written by Claude based on my recommendations.


#1254 §9 explains why the order is what it is and what breaks under the orderings that were rejected. It does not restate the order; this issue owns it. If the order changes, edit here; if the reason changes, edit there.

The release order: stage 4 → stage 5 → NEWS reconciliation → Air's format (optional) → release candidate → 1.0. The candidate ships when everything is ready rather than at the earliest defensible point, so nothing has to be adjudicated as safe-before or safe-after the tag. Air is the one item we may decide not to do at all, and it runs last before the tag rather than after it: a candidate that is not the source we ship is not a candidate. Optionality decides whether Air runs, not when, and that decision is taken when the NEWS reconciliation lands (#1254 §9).

Every stage that changes a public contract downstream has to branch on bumps the dev version in its own pull request. Stages 1, 4 and 5 do; 2, 3 and 3b do not. That gives brms a packageVersion("cmdstanr") boundary from the v1.0 branch the day a break lands there, instead of waiting for the candidate tag. Stages land on that branch, one pull request each, and the branch merges to master at the candidate (#1254, "How the stages are executed"): too many people install from GitHub master to let the breaks reach it one stage at a time. Bumping in a follow-up commit is worse than not bumping: a guard written against the new number then takes the old branch and calls a method that has already gone.

Two things this rule is easy to get wrong. Guards name a stage, not a number chosen now: brms needs the standalone family, which is stage 4, so its boundary is the stage 4 dev version, whatever that pull request assigns. From 0.9.0.9002, stage 1's bump is .9003, so a guard written against .9003 today would move brms to compile_stan_file() three stages before it exists. The downstream pull requests below carry the real numbers. And the trigger is a contract, not observability: stage 3 creates a sidecar beside every executable and can print the untracked-dependency note, both observable, and neither is something a downstream package could write an if against. instantiate carries the record into the package library without needing to do anything, and this is an assertion rather than an open question. Its install.libs.R copies the sources into R_PACKAGE_DIR/bin/stan and compiles there, so the record is written beside the executable and R's move of the staged tree carries both. That is the mechanism #1254 §9, "Its runtime model stays executable-only", already measures with a real R CMD INSTALL, and the reason #1254 §9, "cmdstanr cannot repair an install-time-built model", predicts a 00LOCK-…/00new/… build path. The staged-install test under the downstream pull requests below is what holds it.

One pull request per stage, green and revertable on its own. Only one compiling task runs at a time: make/local and the precompiled headers live in the CmdStan installation, not in the checkout, so separate checkouts do not separate them. Stages 2 and 3b compile nothing, so they are the two that can be worked alongside something else.

Stage 0: landing in #1235
  • #1228
  • #1234
  • #1236
Stage 1: Make-option correctness (merged to v1.0 in #1262, 7dc846e7)
  • #1251: logical FALSE must disable
  • #1250: casing and raw assignments
  • #1230: include path escaping
  • #1232: quoted values in make/local STANCFLAGS. Its include-path example is now a rejection test; the quoting fix gets its own fixture, --filename-in-msg='/my dir/model.stan' in make/local reaching direct stanc as one argument, which stage 4 reuses to assert the drop under the flag-precedence rule leaves no stray element
  • Named cpp_options entries are normalized to their make spelling (uppercase) once, on entry to the build call, after the name-shape check and before the reserved-name checks. cpp_options_to_compile_flags() (R/cpp_opts.R:131) uppercases them on the way out, so list(USER_HEADER = h), list(user_header = h) and list(User_Header = h) are one variable to make and three values to R, and the codebase reconciles that three times in two directions today: toupper() outbound, tolower() in parsed_cpp_options() (:100) inbound, and tolower() again in the dormant validate_cpp_options() (:165). Canonicalizing on entry means validation, comparison, the record and $cpp_options() all see one spelling, and the reserved-variable rejections below match literals instead of folding case themselves. It also retires parsed_cpp_options()'s exclusion list (:101), for a different reason on each entry: user_header cannot reach a supplied list because the named spelling is rejected below, while a supplied STAN_VERSION is an ordinary Make variable that CmdStan itself never reads (the name is one cmdstanr synthesizes at R/cpp_opts.R:68 from the three stan_version_* fields <exe> info prints, and CMDSTAN_VERSION is CmdStan's own version variable), but a user's make/local can read $(STAN_VERSION) off the make command line and change CXXFLAGS with it, so it is recorded and compared like FOO, and excluding it would drop a supplied entry that can change the artifact. exe_info_reflects_cpp_options() (R/cpp_opts.R:327) is re-keyed in this item and deleted in stage 4 (#1254 §3, "Its key fold is changed with this rule, not after it"): it matches the parser's names against tolower(names(exe_info)), so the moment the parser stops folding case that intersection is empty for every option and the check silently returns TRUE. Its caller is the reuse branch of $compile() (R/model.R:777), reached by every fresh session that constructs a source-backed model whose executable exists, so it cannot go before the engine replaces it: twelve assertions across test-model-recompile-logic.R, test-model-generate_quantities.R and test-cpp_opts.R expect its warning. Drop the fold so both sides carry the make spelling. The deletion, with its branch, its tests (test-cpp_opts.R's "exe_info cpp_options comparison works" and "exe_info comparison reads cpp_options the way make does") and exe_info_style_cpp_options() (R/cpp_opts.R:312), whose only caller is the first of those tests, is a stage 4 item under #1255. Its name is wrong anyway: it lists stan_cpp_optims among the flags reported in exe info, and write_stan_flags.hpp reports four, not five. This item also changes what $cpp_options() returns. See the NEWS entry under Before the release candidate. list(stan_threads = TRUE) keeps working. stanc_options are left alone: stanc is case-sensitive and rejects --Warn-Pedantic with a better message than ours
  • Every channel rejection below matches on where the option name occurs, not on enumerated values, because stanc_options_to_args() (R/model.R:2598) puts the flag name in a different slot per entry shape. Reject a named entry whose name is the flag whatever its value, and an unnamed entry whose value is the flag or begins with the flag followed by =. A named entry's name may not contain = (#1254 §3, "A named entry's name may not contain ="): list("include-paths=/b" = TRUE) has a name that is not include-paths and emits --include-paths=/b, so it needs a test of its own, an error naming include_paths. The check goes in assert_valid_stanc_options() (R/model.R:2562) beside the leading-hyphen check. warn-pedantic alone has six spellings the converter treats differently: unnamed, named TRUE, named FALSE, named NA, named NULL (which emits --warn-pedantic=) and named "yes". Two of them emit nothing, so a check keyed on the arguments that reach stanc passes them. make/local is excluded: it is text in CmdStan's own file rather than a list entry, and keeps the substring test noted below
  • include_paths becomes the only channel into stanc's search path. --include-paths supplied through stanc_options (matched on occurrence, per the rule above), through make/local's STANCFLAGS, or through STANCFLAGS in cpp_options reaches the build (R/model.R:837, :839, and a make command-line assignment that cmdstanr's STANCFLAGS += appends to rather than replaces) but not the stanc --info call re-resolution is built on (:2668), so it resolves at build time and nowhere else: a model built that way compiles and then fails on $sample(), which calls $variables() unconditionally (:1410). A live bug in released cmdstanr, never filed. All three are rejected with an error naming the dedicated argument, and STANCFLAGS in cpp_options is rejected outright, since stanc_options is the channel for stanc flags and a raw make-variable passthrough only duplicates it; in the make/local case detection is a substring test on the value Make resolves, not a parse of the file, matching --include-paths or an element beginning with -I, the short spelling stanc added in 2.38, and it runs at build time only, recording nothing (#1254 §6, "The STANCFLAGS check reads what Make resolved, not what make/local says, and runs at build time only"). The two rejections differ in scope on purpose: cpp_options is a cmdstanr argument so the whole STANCFLAGS variable goes, while make/local is CmdStan's own config file (make/local.example:20 suggests STANCFLAGS+= --warn-pedantic) so only the include-path flag is refused there. Put the cpp_options check in assert_valid_cpp_options() (#1250), which cmdstan_make_local() does not call (R/install.R:324-338), so writing STANCFLAGS into make/local through the supported function keeps working. Tests for the make/local arm: STANCFLAGS += --include-paths=/b, STANCFLAGS += -I /b and STANCFLAGS += -I/b, each rejected with the error naming include_paths. Breaking, so it needs a NEWS entry. Must land before stage 3b, whose decision table encodes the rule this makes sound
  • A flag the call emits, supplied or injected, wins over the same flag in make/local's STANCFLAGS: drop the make/local occurrence from the resolved vector before both stanc invocations, matched as the flag itself or the flag followed by =; when the match is the bare flag, the next element goes with it if it does not begin with a hyphen, since that is the flag's value given separately and stanc accepts --filename-in-msg published-model.stan as two arguments; one hyphen, not two, because -fno-soa is a stanc option and must not be consumed as the value of a --warn-pedantic before it (#1254 §6, "A flag the call emits wins over the same flag in make/local's STANCFLAGS"). stanc's handling of a repeat varies by version, 2.37 refuses every repeat and 2.38 and 2.39 refuse valued ones, so pedantic = TRUE against make/local.example's --warn-pedantic line and list("O1") against a make/local --O1 fail the build today on 2.37. Not the include-path rejection, which stays. Tests, each with the flag in make/local and asserting stanc sees it once: pedantic = TRUE with --warn-pedantic; list("O1") with --O1; and at stage 4, the injected --filename-in-msg against a make/local value in each form, --filename-in-msg=published.stan and --filename-in-msg published.stan, the call's winning and stanc seeing no stray published.stan element; and pedantic = TRUE against --warn-pedantic -fno-soa, with -fno-soa still reaching stanc
  • The user_header argument becomes the only channel for the user header. cpp_options[["USER_HEADER"]] and cpp_options[["user_header"]] are rejected with an error naming it. This deletes most of resolve_user_header() (R/cpp_opts.R:189-245), which exists to reconcile the three spellings (both casings tracked positionally for make's last-wins rule, a four-level precedence chain, two conflict warnings) and whose previous parameter #1256 removes along with deferred compilation. Its supplied flag goes with them (#1254 §7, "Explicit NULL means omission for all six, so one sentinel covers them"): the flag exists to give the argument precedence over the two spellings and otherwise to fall back to previous, so with both gone user_header = NULL and an omitted user_header are the same request. initialize()'s "user_header" %in% names(args) test (R/model.R:279) goes with it, and $compile()'s missing() check (:634) goes with $compile(). Add $user_header() so the dedicated argument has a dedicated accessor; without it $cpp_options()[["USER_HEADER"]] is the only way to read the header back. 14 test call sites use the cpp_options spelling. Breaking, needs a NEWS entry
  • Reject allow-undefined in stanc_options, matched on occurrence, with the same error as the header channel above, since it is the flag user_header implies and not an independent setting. Pair it with the rule below so the escape hatch and the thing it escaped are not removed in one step
  • Reject use-opencl in stanc_options, matched on occurrence, naming cpp_options = list(stan_opencl = TRUE). It is the flag stan_opencl implies (R/model.R:676-678), and supplying it alone never produces an OpenCL-enabled executable. It produces one of two other things, chosen by the model. stanc emits matrix_cl members only where a GLM-family function takes data it can move to the device, and those types exist only when STAN_OPENCL is defined, so such a model fails with six C++ template errors instead of one sentence. A model without such a call (bernoulli.stan, measured on 2.39) emits C++ identical but for the embedded stancflags string, builds, and reports STAN_OPENCL=false. That is the worse outcome of the two, since nothing tells the caller their request did nothing
  • Reject name in stanc_options, matched on occurrence, with an error saying the model name comes from the file name. The one rejection with no argument to redirect to, since R/model.R:273 takes the name from the file's basename and nothing else writes it (#1254 §3, "A flag cmdstanr derives from another argument is not separately settable"). It is half-wired today: a supplied name reaches stanc but never touches private$model_name_, so stanc_options = list(name = "foo") leaves $model_name() answering bernoulli while every CSV the binary writes stamps foo. Letting the option write model_name_ would reconcile that and is the alternative considered. It is rejected because it earns nothing, since a model compiled from a string is named through write_stan_file(basename =) (R/file.R:61) and two models that need telling apart in a CSV header need distinct file names anyway. This makes the injections at R/model.R:834 and :1138 unconditional; the option_name != "name" quoting exception in stanc_options_to_args() (:2611) stays, since it guards cmdstanr's own injected flag. One test uses the channel, test-model-compile.R:370-378, and asserts only that the flag is forwarded. Checked: brms and instantiate never mention stanc_options at all, and rethinking's cstan() and ulam() default it to list("O1") and forward the caller's list, so a rethinking user reaches this the way they reach the other rejections. Breaking, covered by the consolidated NEWS entry
  • Delete validate_cpp_options() (R/cpp_opts.R:151) and its tests (test-cpp_opts.R:24-37). It is dead code, called from nowhere in R/, but the reason to remove rather than adopt it is #1251: its one substantive behaviour warns that a logical FALSE will turn an option on, which #1251 reverses, so keeping it would document the opposite of v1.0's semantics. The new checks live in assert_valid_cpp_options() (#1250), pairing with assert_valid_stanc_options() (R/model.R:2562)
  • With no stan_file, an explicitly supplied argument that can only be honoured by building or by reading the source is an error (#1254 §7): cpp_options, stanc_options, include_paths, user_header, force_recompile, pedantic. cpp_options, stanc_options, user_header and force_recompile cannot configure an artifact that will not be rebuilt, and a valid record is there to be inspected rather than overridden. include_paths and pedantic fail on the source instead, and that reason belongs in their messages: include_paths configures source resolution, needed by every stanc invocation whether or not anything compiles, while pedantic asks for a stanc run over a program that is not there, so the guarantee that it reports on every call cannot be kept quietly. Check whether the argument was supplied, not what it resolves to: today's default is getOption("cmdstanr_force_recompile") (R/model.R:621), so a check written as isTRUE(force_recompile) would error for every adoption performed by anyone with that option set, including every instantiate fit from inside a package the user never chose to look at. That default leaves the signature (#1254 §7, "No signature resolves the cmdstanr_force_recompile option"), and that is three functions rather than two, since cmdstanr_example() resolves it in its own signature at R/example.R:62 and hands the answer to cmdstan_model(). Not missing(): a wrapper's own NULL default already makes it FALSE in the shared implementation, and the shared implementation is where the rebuild reason has to tell an explicit force_recompile = TRUE from an option set in .Rprofile months ago. Document on the option's help page that it has no effect on executable-only models, so the advice arrives as documentation rather than as a runtime failure in somebody else's code. Breaking, needs a NEWS entry. Stage 1 reads the captured ... by exact name, so an abbreviation such as force = TRUE or cpp_opt = list() beside exe_file passes the check and partial-matches on the build path (found in review of #1262). Left alone on purpose: removing $compile() makes these arguments the constructor's own formals, which closes the hole with no matching code, and the abbreviated spellings join that item's test matrix
  • Reject warn-pedantic in stanc_options, matched on occurrence, with an error naming pedantic = TRUE. Two channels for one setting, and here they would differ in kind rather than in spelling: pedantic = TRUE is injected and not compared, so #1254 §8 reruns the check on an up-to-date model, while the same flag through stanc_options is supplied and compared, so it warns only when a build happens. The named FALSE has a reason of its own on top of that: it emits nothing today while pedantic = TRUE still injects, so it reads as a way to switch pedantic off and is not one
Stage 2: schema and helper tests (merged to v1.0 in #1264, faa40abf)
  • #1238: the record format, parser, writer and comparison helpers
  • 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 #1254 §4, "This repository needs the patterns too, before Stage 3 writes anything", describes, arriving in our own repository first
  • Validate every required field before accepting a record, in the parser, not in each caller (#1254 §4, "What the reader cannot use, it rejects as unreadable rather than working with"). The required set belongs to the record's own format_version rather than to this cmdstanr: a validator that hardcodes one list turns every older record unreadable the first time a field is added, which is a mass rebuild by the back door (#1254 §6, "Unreadable means the record could not be accepted as a record at all"). Only one format version is live, so there is nothing to fixture here yet and this is a constraint on the validator's shape rather than a test. Checking only the fields the caller at hand happens to need is what leaves one record adoptable by cmdstan_model() in stage 4 and unavailable to stan_build_info() in stage 5, and stage 4 already consumes records, so this cannot wait for the stage that renders them. A record failing any check is unreadable whole: no part of it used, no part reported, including a format_version that parsed. Three fixtures, and the two after the first are the ones an implementation guided by it will get wrong: invalid JSON; a record whose format_version is present and supported but which carries one required field of the wrong type; and a record whose format_version is one this cmdstanr does not read, carrying a body that is invalid under the current schema. Assert that none of the three reaches a comparison, that the first two yield no format_version, and that the third is unsupported_format and reports its version. The third fixture is what pins the order of the two steps (#1254 §4, "The version is checked first, and on its own"): an implementation that validates the fields before looking at the version reports every unsupported record as unreadable, which withholds the number #1254 §4's recompile message prints and leaves unsupported_format unreachable for any record that differs from the current schema
  • Tri-state round-trip tests, distinct from the helper tests above because they check the format rather than the helpers. #1254 §1 encodes reported_features by presence (a key is written only when the state is known) because the obvious NA-for-unknown encoding does not survive: jsonlite writes NA as null and reads it back as NULL, so the R type is gone after one trip through a file and is.na() returns logical(0), which errors in an if. The two states stay recoverable through names(), but not through the access anyone writes: x[["k"]] is NULL either way and !isTRUE(x) is TRUE either way, so unknown and disabled collapse. Assert that known-enabled, known-disabled and unknown survive a write/read cycle as three distinguishable outcomes, and that no null ever appears in a written record. Assert the validator's side of it too, since the round trip passes whatever the validator does: a record whose reported_features omits a flag is readable, not rejected, because requiring today's four would make every record from a CmdStan that stopped reporting one unreadable, rebuilding every model it built that has a source and stripping the provenance from every one that does not (#1254 §4, "reported_features is checked for shape and never for membership"). STAN_CPP_OPTIMS moving is the precedent

Behaviour-free: nothing writes a record beside a user's program until stage 3. The open questions here are all now answered in #1254. The record is a hidden JSON file, .<exe>.cmdstanr.json, written beside the executable. Dependencies are identified by content, with each one's build-time path stored as built_from for provenance and not compared, so moving a project does not rebuild it. The user header's path is compared as well as its content, as one instance of a general rule: directories supplied to cmdstanr for C++ include resolution are compared as spellings, since the C++ closure beneath them cannot be enumerated, the -I flags in cpp_options being the rule's other instance. Includes are compared as an ordered sequence rather than a set. And the record's lifecycle follows the executable's, so whatever ignores the binary ignores the record.

Stage 3: transactional record writing (merged to v1.0 in #1265, 6a5572a5)
  • #1238: staged install of executable and record as a pair, with rollback
  • The interleaved-writes case as a test of the pair verification (#1254 §4, "The record must contain a hash of the executable it describes"). Two builds race to one destination: A installs its executable, B installs its executable and then its record, and A installs its record last. What is on disk is executable B beside record A, and atomic replacement of either file alone cannot see it. No real concurrency is needed: write the four files by hand in that order and run the verification step the transaction ends with. It must fail on the hash, and a read of that pair must report a mismatch rather than describe B with A's record. Locking stays out of scope; this pins that the hash catches what ordering cannot
  • Enumerate the fields the writer populates, and check each against this stage. This is where records begin, so every field in #1254 §4's table must be computable here: a field whose computation lands later means stage 3 writes a value that is wrong rather than merely absent, and a record that still matches later is never rewritten to correct it. Four of §4's rows fail that check as originally staged, addressed by the two prerequisite items below: known_untracked_dependencies, plus the three option rows that depend on the split, cpp_options_supplied, stanc_options_supplied and stanc_options_injected, none of which is computable while injections land in the caller's list. reported_features passes and is captured here even though the live behaviour that consumes it is stage 4, which is worth stating so the capture does not look deferred too. The rest, stanc_name, include_paths, the dependencies fields (the user header among them), artifact, builder and format_version, are computable today; stanc_name is the effective name, which the merged list already holds, so it needs the split for ordering rather than for correctness. tbb_dir is the one row left over, and it is computable here too, from the call's own cpp_options rather than from anything the executable reports, which is why it has its own item below rather than a place in this list
  • Stop merging the injections into the user's list. Moved forward from stage 4: the record cannot be written correctly without it. R/model.R:673, :677, :693 and :835 all write into the same stanc_options variable, so by the time the writer runs the user's entries and cmdstanr's are indistinguishable. cpp_options needs no accumulator, but it does need a deletion, and it is the worse bug: :709 writes the resolved user header back in under whichever spelling was used, and :941 stores that list, so $cpp_options() today reports USER_HEADER = "/abs/path/inc/mine.hpp" for a caller who passed user_header = "inc/mine.hpp" as an argument and never touched cpp_options. That one does not add an entry beside the caller's, it replaces the caller's value with a resolved, wsl_safe_path()-transformed absolute path, so cpp_options_supplied read off that variable is wrong even with no concept of injection at all. USER_HEADER= still has to reach make (make/program:41) and does so as a flag built with the others, not as a recorded cpp_options entry, since recording it would hold the header's path in request as well as in dependencies, and under WSL not even in the same spelling (#1254 §3, "It reaches it as a flag built with the others"). resolved_header$spelling dies with :709, its only consumer. With :709 gone, cpp_options holds exactly what the caller passed, which is why there is no cpp_options_injected field to build and the accumulator work below is stanc_options only. Stage 3 would then have to either put the merged list into _supplied, which silently makes every injection a compared option and makes toggling pedantic recompile, or reconstruct the split by subtraction, which is the reconstruct-after-the-fact fragility #1254 §4, "Origin is stored, not inferred", rejects by name. Accumulate injections into their own list and merge only when converting to arguments, so _supplied and _injected are both values the code already holds. Do not solve it with a snapshot taken before the first injection site; that works until someone adds a fifth one above it. Behaviour-free on its own
  • Record request.stanc_name, the --name stanc receives, as a separate compared field as well as its place in stanc_options_injected: the injected list records who asked, this records what stanc got, and only the second is compared (#1254 §4). With name rejected from stanc_options in stage 1, the derived value is the only one there is. It rides on the item above, since R/model.R:835 is one of the four sites the accumulator splits, but it is separate because it is compared and the injected list is not. Without it, moving a source, its executable and its record together under a new name changes nothing compared (content hash, artifact hash and builder all match, supplied options are empty on both sides), so the binary is reused while $model_name() and the name compiled into it disagree. That contradiction is visible inside R, not just in the CSV text: R/csv.R:873 maps the CSV header onto fit$metadata()$model_name. What the comparison buys is where the CSV boundary falls, not avoiding one: uncompared, the stamp changes on whatever unrelated rebuild comes next and check_csv_metadata_matches() (:948-951) rejects the mixture then. The field is named stanc_name rather than model_name so that it cannot be read as a second answer to mod$model_name(), which returns the same name without the suffix. Record the value as passed, including the _model suffix R/model.R:835 appends. Not because it is what CmdStan stamps, which it is not: stanc mangles characters that cannot appear in a C++ identifier, and by hex escape rather than substitution. Measured identical at 2.35, 2.36 and 2.39: my-model_model compiles to my_model_model, my.model_model to myx46model_model, my+model_model to myx43model_model. Record what is passed, because that is what both sides of the comparison compute the same way, and because reproducing a compiler's mangling in R would drift silently in the direction that does not rebuild. Assert it with a my-model.stan fixture, whose record must hold the raw my-model_model: an implementation that reads the name back off the built binary, or normalises it on the way in, passes every fixture whose name is already a legal identifier. Two consequences are accepted rather than fixed: a punctuation-only rename rebuilds although the compiled name is unchanged, the artifact differing only in the raw string CmdStan writes onto a stancflags line in each sampler output CSV, which cmdstanr does not parse; and $model_name() still will not match the compiled name for a mangled file, which is true today. The comparison itself belongs to stage 3b's decision table. No NEWS entry of its own, since it is covered by the consolidated rebuild-feature entry below
  • #1257: run both detectors, populate known_untracked_dependencies, and emit the write-time note. Moved forward from stage 4 for the same reason: #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 #1254 §6, "The field is known_untracked_dependencies, not provenance_complete", spends a subsection prohibiting. The regexes are the two in #1254 §6, "Provenance we cannot complete", which now include GNU Make's sinclude spelling. It is a fixed keyword, so it costs one alternation and no Make parsing. Test positive and negative detection for both, with sinclude among the positive make/local cases, and that the note fires on a successful write and not otherwise. Displaying the field through stan_build_info() stays in stage 5
  • Record tbb_dir, the absolute TBB directory the call named (#1254 §4, the tbb_dir row). Read the call's cpp_options as make receives them, last assignment wins and FALSE is an empty one, and take the first non-empty of TBB_LIB, TBB_BIN and the installation's own lib/tbb, which is the order the makefile links in. Resolve a relative directory against the installation, where make runs. Not asked of make: a TBB_LIB or TBB_BIN from make/local, ~/.config/stan/make.local or the environment moves the linked TBB and not the field, so such a build launches on Windows with the installation's TBB first, as today; the query was tried and dropped because CmdStan's print-% echoes through a shell that misspells Windows paths. #1261 is the consumer. Tests: a default-layout build recording the installation's own lib/tbb; a build with cpp_options = list(tbb_lib =) recording that directory and not the default; a relative TBB_LIB recorded absolute; and the helper on a repeated TBB_LIB, a FALSE one with TBB_BIN beside it, and a FALSE one alone
  • Document the record's lifecycle where users will look for it (#1254 §4, "In practice that means .gitignore"). The rule is one line: whatever ignores the executable ignores the record, and wherever the executable goes the record goes with it. It needs three concrete cases. Add .*.cmdstanr.json beside whatever already excludes the binary in .gitignore. Do the same in .Rbuildignore, which is the easier miss: R CMD build excludes hidden files by a fixed 28-entry list (tools:::.hidden_file_exclusions) that does not include this name and does not match on leading dot, so a package author who compiles in a source tree ships records describing their own machine. And any staging step that copies the executable copies both: CI artifacts, container layers, shared build directories. Home is vignettes/cmdstanr-internals.Rmd in the Compilation section beside "Executable location", which is already where the vignette says where the binary goes. Lands with the writer: before this stage there is no record to ignore, after it every compiled model has one
Stage 3b: the assessment engine, pure and unwired (merged to v1.0 in #1269, 390daa22)
  • The rebuild assessment as a pure function with its full decision table, tested against stage 2's fixtures, called by nothing
  • A gone builder as a fixture, asserting no rebuild. A record whose builder path does not exist, with that same installation selected and every compared field matching, must contribute no rebuild reason (#1254 §6, "A missing builder is reported, and is not itself a rebuild trigger"). What it pins is that the engine never stats the recorded path: builder is compared as a normalized path and a version, and an implementer who adds an existence check turns a reported condition into a rebuild reason. The live call on this fixture does not reach the engine at all: the installation that is gone is also the selected one, so a source-backed model errors before assessment (#1254 §6, "A selected installation that is gone is its own error, checked where it is used"), which stage 4 tests separately. Pair it with the same record under a different, existing selected installation, which must rebuild on builder differing, which is the ordinary row, and not on the absence. No CmdStan installation is needed for either: the engine takes expected and observed and reads no disk of its own, and a path that does not exist is the cheapest fixture there is
  • A builder mismatch as a fixture, asserting the reason list and not only the verdict. Re-resolution is skipped exactly when the selected installation differs from builder, so this fixture's observed carries an unresolved dependency set rather than hashes. It must return rebuild naming the builder and saying nothing about the dependencies (#1254 §6, "It also needs the sources to have been resolved"). The mismatch is itself a trigger, so the verdict is the same whichever way the engine reads that set, which is why a decision table tested only on verdicts stays green against an implementation that reads unresolved as empty and reports every included file as changed. An unresolved set is the difference between a program that includes nothing and one nobody looked at, and this is the fixture that keeps it inside the engine rather than in an undocumented branch in front of it
  • The rename case as its own fixture pair, because it is the only single-field fixture here that can fail. A record with stanc_name = "bernoulli_model" against a request identical in every other field (same dependency content hashes, same artifact hash, same builder, empty supplied options on both sides) must return rebuild, with the reason naming the model name (#1254 §4, "--name is compared as its own row because the build bakes it into the binary and nothing else compared pins it down"). Every other fixture changes a field some other row already covers, so a decision table that never grew a stanc_name row passes all of them green. Add the punctuation-only rename beside it, my-model.stan to my_model.stan, which must also rebuild even though both compile to my_model_model, so the over-rebuild #1254 §4 accepts is pinned as intended rather than left for someone to normalise away
  • A replaced executable, which is the one fixture here that tests an argument rather than a row. expected carries artifact hash H1, while observed carries a binary hashing to H2 beside a record whose artifact is H2: a pair on disk that is self-consistent, with every other compared field matching. It must return rebuild (#1254 §5, "Only the object's own snapshot catches a replaced executable"). An engine written to take the record and the request has nowhere to put H1, so it returns no trigger here while every other fixture in this stage stays green. Nothing is compiled and no installation is needed: any two files with different bytes supply H1 and H2, and the record is fabricated as elsewhere in this stage
  • The two-headers case as a fixture, asserting rebuild on the path with the content unchanged (#1254 §6, "The user header is therefore matched on normalised path and content"). A record with dependencies.user_header.built_from under exact/ and hash H, against observed holding the same hash H under approx/, with every other field matching, must return rebuild naming the user header. Content-only identity passes every other fixture in this stage and fails this one. The live form is the pair of byte-identical headers in #1254 §6 whose odds_impl.hpp differ, giving mean(odds) of 0.3261 and -1.0 from the same compared fields; build that only if an end-to-end version is wanted, since the fixture needs no compiler
  • First, enumerate every cmdstanr argument that becomes a stanc or cpp option. The decision table's central rule is that user-supplied options are compared and injected ones are not, with stanc_name the one injected value compared in its own right (#1254 §4, "An injection nothing compares still applies"), so the table cannot be written correctly against an unknown injection set. R/model.R:672-693 is where the injections happen, but the audit is the argument-to-option mapping rather than the mutation sites. pedantic = TRUE becoming --warn-pedantic is the one that was missed entirely through five review rounds, and it was found by accident. This is the defect class tests do not reach: a rule nobody wrote down is not a rule any test enforces. About an hour of reading The audit found every injection compared through its cause or deliberately uncompared and changed no code.
  • Stanc option comparison needs both directions pinned: the one collapse and the two rebuilds. The comparison is on the argument vector the options emit, in the order given (#1254 §4, "Order is preserved, so a reordered list rebuilds"). Emitting is what collapses spelling, so list("O1") and list("O1" = TRUE) must not rebuild each other, which is the assertion that fails if the implementation compares the R list. The two rebuild cases have separate jobs. list("O1") against a record built with list("O0") must rebuild, and fails against an implementation that never compares stanc options at all. list("O1", "warn-uninitialized") against a record built with list("warn-uninitialized", "O1") must rebuild too, and that one fails against an implementation that sorts the vector before comparing it. Use flags that survive stage 1's rejections, which rules out warn-pedantic, allow-undefined, use-opencl, name and include-paths as fixture material. The order case earns a fixture rather than only a paragraph because it is the one that reads as a bug: measured on 2.39.0, --O1 --O0 and --O0 --O1 both generate the code plain --O0 does and differ only in the stancflags string stanc embeds. The fixture pair is identical for a plainer reason, --warn-uninitialized changing no generated code at all, so either way the rebuild the assertion pins produces the same C++ twice. #1254 §4 prices that and accepts it, because the sort that would avoid it needs the semantics of every stanc option

Depends only on stage 2, and can be worked in parallel with stage 3. It takes two arguments and returns a verdict (#1254 §5, "Two arguments: what this caller expects, and what is on disk"). The record is part of the observed side rather than an argument of its own, and the expected side is what differs between the two callers: at cmdstan_model() it is the options this call supplied, and at a guarded method it is the object's own snapshot, including the artifact hash it was built against. observed carries either the resolved source hashes or a statement that they were not resolved (#1254 §5, "either the source hashes resolved with this call's include paths or a statement that they were not resolved"), which is what keeps the one path that skips re-resolution inside the contract instead of a branch in front of it. It never compiles, never mutates, and nothing invokes it yet, so it is behaviour-free in the same sense stage 2 is and revertable on its own.

It is separated from stage 4 because it is what makes §6 of #1254 checkable. Every rebuild trigger becomes a test with a fixture, and two rules that contradict each other stop being two paragraphs a reader has to hold against each other and become a red suite. That is not a hypothetical: §6 carried "include_paths is not compared as a spelling" and "re-resolution uses the recorded paths" eight lines apart for a full review round, and a test asserting that switching include_paths from v1/ to v2/ rebuilds fails immediately against the second rule. Landing this early moves that check months ahead of stage 4 and shrinks stage 4 to the part that changes behaviour.

This does not weaken the argument below that #1255 and #1256 ship together. That argument is about the engine being live while $compile() is gone; an unwired function changes nothing a user can observe.

Stage 4: the API change and the decision engine, together (merged to v1.0 in #1273, 97030b4f)
  • #1255: wire the assessment engine in: rebuild when the recorded build no longer matches (closes #1019). Delete exe_info_reflects_cpp_options(), its branch in the reuse path (R/model.R:777), exe_info_style_cpp_options() and their tests here, since a cpp_options mismatch is now a rebuild; the twelve assertions that expect the mismatch warning turn into rebuild assertions (#1254 §3, "That leaves the parser with one caller, because the other is deleted in Stage 4")
  • §1's request/report split goes live: $cpp_options() reports only what the caller asked for, and the runtime validators 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 #1235 added), wire $cpp_options() to cpp_options_supplied, and carry reported_features as the tri-state §1 defines (known enabled, known disabled, unknown), with absence never collapsed to disabled. Then move assert_valid_threads() (R/cpp_opts.R:282) and assert_valid_opencl() (:271) onto it at all twelve sampling entry points: requesting a feature the binary reports disabled or unknown is an error, replacing today's warn-and-discard, which is the silently single-threaded four-hour run; and the converse error for a threading-enabled binary run without threads_per_chain (:297-303) is removed, since an artifact exceeding the request is not a mismatch. Removing that error exposes a leak: the four launch sites (R/run.R:478, :545, :591, :648) write STAN_NUM_THREADS into the R session when threads are supplied, CmdStan reads it as the default when num_threads is absent, so an omitted call inherits the previous call's count. Scope the variable to the child through processx::process$new(env = ), which wsl_compatible_process_new() forwards, set when supplied and absent otherwise, and move the WSLENV export with it (#1254 §1, "Removing the error exposes a leak, and the count moves to the child process"). Not num_threads= on the command line: CmdStan refuses to start when it disagrees with a set STAN_NUM_THREADS. Tests: a consecutive-call test, threads_per_chain = 4 then an omitted call, asserting metadata()$threads_per_chain is 1 on the second; the thirteen Sys.getenv("STAN_NUM_THREADS") assertions in test-threads.R become one that the session variable is unchanged by a run. The two halves have a required order and it is easy to get backwards. Validators may move to reported_features before the merge is deleted, since the merge is then redundant, but deleting the merge first re-breaks #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 #765 again. And one must be info-backed, because a record is not unknown's only source: a mocked <exe> info returning a valid version with STAN_THREADS absent must construct with threading unknown and error on threads_per_chain = 2 the same way. Adoption admits an executable on a valid version rather than on a full flag set (#1254 §1, "Unknown is not expected from a supported binary, but it is possible"), so this path is reachable and nothing else here covers it. An implementation satisfying only the record-backed case can still read a missing key as FALSE on the live path. The fixture is nearly free: stage 4 already builds a fabricated hash-bound record for the builder test. Adoption sources the same accessor from the record rather than the call, per the item below. Needs its own NEWS entry for the validator change: threads_per_chain against a non-threaded build now errors where it warned, and the built-with-threading-but-not-using-it error is gone
  • Record-aware adoption (#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" (#1253). With §7 forbidding build configuration here and #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 #1249, which can land independently first off the STAN_VERSION model_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 (#1254 §7, shares #1246's error). This is the only place a version arrives from an artifact nobody vouched for, so it is the only place the invariant §10 leans on can be established. The record's half is not enforced here: an unparseable builder version fails the reader's field checks like any other malformed field (#1254 §4, "What the reader cannot use, it rejects as unreadable rather than working with"), so such a record is not usable and falls to the branch below. What this item enforces is the fallback: <exe> info must report complete version fields. Syntactic only, since rejecting a version for being old would defeat §7, whose point is that binaries built by older CmdStan keep working. Without it model_compile_info() synthesises ".." from three absent fields (R/cpp_opts.R:68), which passes every guard cmdstan_version_compare() has, so construction succeeds and the failure surfaces later inside a version gate as a TRUE/FALSE complaint. Test an info result missing the version fields and one printing a malformed value. Unusable record (missing, unreadable, hash mismatch, unsupported format, or an unparseable builder version, which #1254 §4 makes a field-check failure like any other): fall back to <exe> info. If it reports a valid version, construct silently with unavailable provenance, together with the reported_features the binary supplies and $cpp_options() empty, never an invented request. If it does not, error. So three outcomes, not two (#1254 §7, "Executable-only models are kept, and adoption has three outcomes"), and only the first two permit fitting and skip the automatic rebuild. Drop the unused version parameter from model_compile_info() (R/cpp_opts.R:52) while rewriting its callers: three call sites pass self$cmdstan_version() into a body that never mentions it, which reads as though the version participates. Tests: the counting mock from #1235 extended with a zero-query row for valid-record adoption, which is the only thing separating the design's cost from an implementation that reads the record and spawns the process anyway; a fabricated record with builder at 2.35 under a 2.39 session asserting $cmdstan_version() reports 2.35, which needs no second CmdStan installation; missing, corrupt, unsupported-version and hash-mismatched records each falling back correctly; and $cpp_options() empty versus recorded across the two cases. Needs a NEWS entry, and not the one the no-launch rule reads like: launching was already best-effort, since run_info_cli() passes error_on_status = FALSE (R/cpp_opts.R:17), so a binary that cannot run constructs successfully today too. Measured: a six-byte file with the execute bit exits 126 and yields a model whose $cmdstan_version() answers the session's 2.39.0, while the same file without the bit dies on a raw processx_exec error, which is #1246. The two "cannot run" paths disagree today, and this replaces both. What changes is the unusable-record path: adoption now errors, because neither the record nor <exe> info yields a syntactically valid version, where today construction succeeds and R/model.R:318 attributes the session's CmdStan version to a binary cmdstanr never spoke to. Nothing that would have sampled is refused (#1254 §7, "Both paths must yield a syntactically valid version, and adoption fails if neither does"). The no-launch rule itself stays a design statement rather than a change (#1254 §7, "Adoption establishes what the artifact is, not that it runs")
  • An executable-only model does not rebuild when CmdStan changes (#1254 §9, "The reason is that registering source hands the rebuild decision to the session, and builder guarantees it fires"). Adopt an executable whose valid record names a builder other than the selected installation, and assert that construction succeeds, nothing is compiled, and a guarded method runs. Beside it, the same record under a source-backed construction must rebuild on builder, which is what shows the difference is the missing source rather than the engine. The fabricated-builder record from the $cmdstan_version() test above is the fixture, so no second installation is needed. This is the Monday-to-Wednesday case in #1254 §9: a package installed against one CmdStan, install_cmdstan() the next day, and a fit the day after that must not compile into the package library
  • Guard the public surface per #1254 §5's classification, and make the classification self-enforcing. Ten members validate and error on any trigger; the rest must not, and the must-nots carry equal weight, since guarding $format() or $code() would be a regression §5 argues against explicitly. Rather than a static checklist that rots on the next added method, enumerate the live surface with CmdStanModel$public_methods and $public_fields and fail on any member without a classification. Compare against the table's non-removed method rows and assert $compile()'s absence separately. Do not write the assertion against a count: the table is the union of today's surface and 1.0's, so it carries one method row more than 1.0 has members, and a test keyed on the number of rows fails at 1.0 against its own table. #1254 §5 does that arithmetic and is where the numbers belong; copying them here is how the schema-row count went stale last round. Then exercise every member, not one per class: a representative passing says nothing about the other guarded methods, each of which can be classified correctly here and still run a stale executable. Call each guarded method with no other arguments against a stale model, so a method that validates late fails with a missing-argument complaint instead of the staleness error and the matrix checks ordering rather than only presence; none of them get far enough to need MPI, data or an algorithm. The must-nots take the opposite assertion, not the same one. A non-guarded method has no obligation to succeed bare ($save_hpp_file() wants a destination, $expose_functions() wants Rcpp), so requiring the bare call to pass would fail on argument handling while claiming to test staleness. Assert instead that whatever it raises is not the staleness error. The matrix is then exact, and exhaustive by construction rather than by adding up: every guarded method called bare and asserted to raise it; every non-guarded method called bare and asserted not to; $initialize() classified but never invoked (calling it on a live object retargets private state); the functions public field inspected rather than called, and likewise asserted not to raise it; $compile() asserted absent. A member added later joins the matrix instead of breaking a total. Give the staleness error a condition class in this stage. Most of this matrix is negative assertions, and a negative assertion matched on message text passes forever the moment the message is reworded. $clone() is additionally asserted not to error, and $expose_functions() needs an explicit skip where Rcpp exposure is unavailable, since a silent skip drops a guarded member from the matrix without the enumeration noticing. §5's own justification for listing $initialize() and $clone() is that an unlisted member is indistinguishable from an overlooked one, which is a property a test can hold and a review cannot
  • The replaced-executable regression, end to end, which stage 3b's fixture cannot reach. Construct object A from a source; rebuild the same executable path through a second object B, so the executable and the record beside it are again a matching, self-consistent pair; then assert that a guarded operation on A errors. Stage 3b proves the engine reads an expected artifact hash, and this proves the object carries one: an implementation that fills expected from the record found on disk passes stage 3b's whole decision table and fails only here (#1254 §5, "Nothing on disk disagrees, so the disagreement has to be carried in")
  • The mtime case, end to end (#1254 §4, "The bug this fixes"). Build a model, then overwrite its Stan source with different content and set the file's mtime older than the executable's with Sys.setFileTime(), which is what tar -x, cp -p and a backup restore leave behind. cmdstan_model() must rebuild naming the Stan program, and a guarded method on the already-constructed object must error. The engine compares hashes and cannot see a timestamp, so this is not a stage 3b fixture; it pins that no caller has fallen back to file.mtime(), as R/model.R:732-733 does today, since such an implementation passes every test whose edit is newer than its binary and fails only here
  • A failed re-resolution is an error carrying stanc's message, at the constructor and at every guarded method, and nothing rebuilds (#1254 §5, "A re-resolution that fails is an error, not a verdict"). The engine is never called, since the caller has no resolved hashes to hand it. Tests: a constructor and one guarded method, each against a program whose include is missing, both erroring with stanc's message and neither rebuilding
  • A guarded method on an executable-only model checks the artifact hash alone, whether the object was adopted with a record or without one (#1254 §7, "A guarded method on an executable-only model checks the artifact hash alone"). No record is read and no source is resolved, so a record deleted after construction changes nothing. Tests: adopt a recordless executable, replace it at the same path, and a guarded method refuses; an unchanged recordless executable still runs
  • #1256: remove deferred compilation and $compile(), add the standalone family
  • _pkgdown.yml entries for the standalone family (compile_stan_file, format_stan_file, check_syntax_stan_file, variables_stan_file) and removal of any topic the same pull request deletes. The reference index is an 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. Outcome: no index change was needed, because each standalone function is documented in the topic of its method twin (compile_stan_file() under cmdstan_model, the other three under model-method-check_syntax, -format and -variables), and the index already lists those
  • Remove compile_model_methods and compile_standalone, in the #1256 pull request (#1254 §8). Neither is build configuration: they run expose_stan_functions() and expose_model_methods() after make finishes (R/model.R:963, :966) and change no make flag and no byte of the executable, which is why neither appears in compile_impl()'s signature. Both are already dropped in silence whenever the executable is current, because $compile() returns at :804 and the exposures sit past it, so the same call populates functions or not depending on whether a rebuild happened to be needed. The replacements are the ones their own roxygen already recommends: fit$init_model_methods() (:551) and $expose_functions() (:556). fit$init_model_methods() fails on the same reuse path today, since the model C++ it compiles from is generated only inside the build branch (R/model.R:848) and the fit copies an empty environment; test-model-methods.R:108 asserts that error. #1254 §5 puts that C++ in the construction snapshot on both paths ("The model's generated C++ is part of the snapshot, for the same reason"): the same get_standalone_hpp() call, made before the build-or-reuse branch instead of inside it, written to a tempfile for $hpp_file() as the build branch does today. test-model-methods.R:108 flips to expecting success. One combination test: construct on a current executable, $sample(), init_model_methods(), log_prob() returns a finite value. Drop the roxygen caveats that $hpp_file() errors when an executable was reused (R/model.R:446-448, :478-480). $expose_functions() must be fixed in the same pull request, because removal makes it the only route and it fails on the same path: expose_stan_functions() refuses when function_env$existing_exe is TRUE (R/utils.R:1217), and :267, :299 and :786 together leave it TRUE for a source-backed model whose executable is up to date, so cmdstan_model("m.stan") followed by $expose_functions() errors "Exporting standalone functions is not possible with a pre-compiled Stan model!" about a model that has a source. Make existing_exe mean "this model has no source", and generate the hpp on demand from the registered source. 16 test references to update, across test-model-expose-functions.R, test-model-methods.R and test-fit-shared.R. Breaking, needs a NEWS entry and migration text, and the migration text has to name the pass-through: brm(stan_model_args = list(...)) and instantiate's ... both forward to cmdstan_model(), so scripts break through packages that never mention either argument
  • #1245: moved here from "independent" by the design review, which is right that this stage subsumes it. Its discriminator dissolves: hpp_code off the internal build call is the model's generated C++ on both paths, so a source-backed model always has it and an executable-only model never does (#1254 §8, §5), which answers the "is there generated C++?" half the issue asks for, and the other half stops existing once compile = FALSE and public dry_run go, since a model object with no executable becomes unconstructable. The guard belongs to the item above: #1254 §8, "$expose_functions() is fixed here too, since removal makes it the only route". What is left over is the existing_exe to has_generated_cpp rename across 13 sites, and it should not land first: the issue plans the rename and the guard fix as one edit because the field's only read sits directly above the message, and this stage changes that read to a different question, whether the model has a source. Its user-visible half is reachable today on the ordinary path and could ship earlier as a plain bug fix: measured on 2.39.0, cmdstan_model(f) twice followed by $expose_functions() errors with "not possible with a pre-compiled Stan model" on a model whose source is beside it, no compile = FALSE involved. That buys earliness rather than correctness, since this stage replaces it with generating the hpp on demand. Outcome: existing_exe is gone rather than renamed; expose_stan_functions() asks whether the model carries generated C++ (hpp_code), and a model built from a Stan file always does
  • The selected-installation check, immediately before make or a tool is invoked out of it rather than as a general precondition (#1254 §6, "A selected installation that is gone is its own error, checked where it is used"). make runs in the selected installation (R/model.R:862-866) and so does stanc, which stanc_cmd() names relatively as bin/stanc (R/utils.R:123-129): the build, the re-resolution an assessment needs whenever the selection is also the recorded builder, the construction-time stanc --info (R/model.R:2673) that §5's snapshot is taken from, and $check_syntax() (:1151) and $format() (:1278) with their standalone twins, which conduct no assessment and reach it anyway. It reaches past the model object as well: fit$cmdstan_summary() and fit$cmdstan_diagnose() run bin/stansummary and bin/diagnose with the selected installation as their working directory (run_cmdstan_tool(), R/run.R:316) and build them on demand with a make in that same tree (check_target_exe(), :414). Both live on CmdStanFit, so a check writte

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start with dev-notes/compilation-state.md and issue #1254, then follow the stage-specific issues and files named here, including R/cpp_opts.R, R/model.R, and R/install.R. This is a release-order tracker rather than a single task; done means the stages, NEWS reconciliation, optional Air decision, candidate, and 1.0 release occur in the stated order with each downstream pull request green and revertable.

Written by the indexing model from the issue text.

Assessment

Tech stack
r
Domain
build-system, release, tooling
Issue type
Feature
Difficulty
5/5
Estimated time
Over a week
Activity status
Active
Clarity
Needs clarification
Newbie friendliness
25/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.