v1.0 compilation state: implementation order
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
FALSEmust disable - #1250: casing and raw assignments
- #1230: include path escaping
- #1232: quoted values in
make/localSTANCFLAGS. Its include-path example is now a rejection test; the quoting fix gets its own fixture,--filename-in-msg='/my dir/model.stan'inmake/localreaching direct stanc as one argument, which stage 4 reuses to assert the drop under the flag-precedence rule leaves no stray element - Named
cpp_optionsentries are normalized to theirmakespelling (uppercase) once, on entry to the build call, after the name-shape check and before the reserved-name checks.cpp_options_to_compile_flags()(R/cpp_opts.R:131) uppercases them on the way out, solist(USER_HEADER = h),list(user_header = h)andlist(User_Header = h)are one variable tomakeand three values to R, and the codebase reconciles that three times in two directions today:toupper()outbound,tolower()inparsed_cpp_options()(:100) inbound, andtolower()again in the dormantvalidate_cpp_options()(:165). Canonicalizing on entry means validation, comparison, the record and$cpp_options()all see one spelling, and the reserved-variable rejections below match literals instead of folding case themselves. It also retiresparsed_cpp_options()'s exclusion list (:101), for a different reason on each entry:user_headercannot reach a supplied list because the named spelling is rejected below, while a suppliedSTAN_VERSIONis an ordinary Make variable that CmdStan itself never reads (the name is one cmdstanr synthesizes atR/cpp_opts.R:68from the threestan_version_*fields<exe> infoprints, andCMDSTAN_VERSIONis CmdStan's own version variable), but a user'smake/localcan read$(STAN_VERSION)off the make command line and changeCXXFLAGSwith it, so it is recorded and compared likeFOO, and excluding it would drop a supplied entry that can change the artifact.exe_info_reflects_cpp_options()(R/cpp_opts.R:327) is re-keyed in this item and deleted in stage 4 (#1254 §3, "Its key fold is changed with this rule, not after it"): it matches the parser's names againsttolower(names(exe_info)), so the moment the parser stops folding case that intersection is empty for every option and the check silently returnsTRUE. Its caller is the reuse branch of$compile()(R/model.R:777), reached by every fresh session that constructs a source-backed model whose executable exists, so it cannot go before the engine replaces it: twelve assertions acrosstest-model-recompile-logic.R,test-model-generate_quantities.Randtest-cpp_opts.Rexpect its warning. Drop the fold so both sides carry themakespelling. The deletion, with its branch, its tests (test-cpp_opts.R's "exe_info cpp_options comparison works" and "exe_info comparison reads cpp_options the way make does") andexe_info_style_cpp_options()(R/cpp_opts.R:312), whose only caller is the first of those tests, is a stage 4 item under #1255. Its name is wrong anyway: it listsstan_cpp_optimsamong the flags reported in exe info, andwrite_stan_flags.hppreports four, not five. This item also changes what$cpp_options()returns. See the NEWS entry under Before the release candidate.list(stan_threads = TRUE)keeps working.stanc_optionsare left alone: stanc is case-sensitive and rejects--Warn-Pedanticwith a better message than ours - Every channel rejection below matches on where the option name occurs, not on enumerated values, because
stanc_options_to_args()(R/model.R:2598) puts the flag name in a different slot per entry shape. Reject a named entry whose name is the flag whatever its value, and an unnamed entry whose value is the flag or begins with the flag followed by=. A named entry's name may not contain=(#1254 §3, "A named entry's name may not contain="):list("include-paths=/b" = TRUE)has a name that is notinclude-pathsand emits--include-paths=/b, so it needs a test of its own, an error naminginclude_paths. The check goes inassert_valid_stanc_options()(R/model.R:2562) beside the leading-hyphen check.warn-pedanticalone has six spellings the converter treats differently: unnamed, namedTRUE, namedFALSE, namedNA, namedNULL(which emits--warn-pedantic=) and named"yes". Two of them emit nothing, so a check keyed on the arguments that reach stanc passes them.make/localis excluded: it is text in CmdStan's own file rather than a list entry, and keeps the substring test noted below -
include_pathsbecomes the only channel into stanc's search path.--include-pathssupplied throughstanc_options(matched on occurrence, per the rule above), throughmake/local'sSTANCFLAGS, or throughSTANCFLAGSincpp_optionsreaches the build (R/model.R:837,:839, and a make command-line assignment that cmdstanr'sSTANCFLAGS +=appends to rather than replaces) but not thestanc --infocall re-resolution is built on (:2668), so it resolves at build time and nowhere else: a model built that way compiles and then fails on$sample(), which calls$variables()unconditionally (:1410). A live bug in released cmdstanr, never filed. All three are rejected with an error naming the dedicated argument, andSTANCFLAGSincpp_optionsis rejected outright, sincestanc_optionsis the channel for stanc flags and a raw make-variable passthrough only duplicates it; in themake/localcase detection is a substring test on the value Make resolves, not a parse of the file, matching--include-pathsor an element beginning with-I, the short spelling stanc added in 2.38, and it runs at build time only, recording nothing (#1254 §6, "TheSTANCFLAGScheck reads what Make resolved, not whatmake/localsays, and runs at build time only"). The two rejections differ in scope on purpose:cpp_optionsis a cmdstanr argument so the wholeSTANCFLAGSvariable goes, whilemake/localis CmdStan's own config file (make/local.example:20suggestsSTANCFLAGS+= --warn-pedantic) so only the include-path flag is refused there. Put thecpp_optionscheck inassert_valid_cpp_options()(#1250), whichcmdstan_make_local()does not call (R/install.R:324-338), so writingSTANCFLAGSintomake/localthrough the supported function keeps working. Tests for themake/localarm:STANCFLAGS += --include-paths=/b,STANCFLAGS += -I /bandSTANCFLAGS += -I/b, each rejected with the error naminginclude_paths. Breaking, so it needs a NEWS entry. Must land before stage 3b, whose decision table encodes the rule this makes sound - A flag the call emits, supplied or injected, wins over the same flag in
make/local'sSTANCFLAGS: drop themake/localoccurrence from the resolved vector before both stanc invocations, matched as the flag itself or the flag followed by=; when the match is the bare flag, the next element goes with it if it does not begin with a hyphen, since that is the flag's value given separately and stanc accepts--filename-in-msg published-model.stanas two arguments; one hyphen, not two, because-fno-soais a stanc option and must not be consumed as the value of a--warn-pedanticbefore it (#1254 §6, "A flag the call emits wins over the same flag inmake/local'sSTANCFLAGS"). stanc's handling of a repeat varies by version, 2.37 refuses every repeat and 2.38 and 2.39 refuse valued ones, sopedantic = TRUEagainstmake/local.example's--warn-pedanticline andlist("O1")against amake/local--O1fail the build today on 2.37. Not the include-path rejection, which stays. Tests, each with the flag inmake/localand asserting stanc sees it once:pedantic = TRUEwith--warn-pedantic;list("O1")with--O1; and at stage 4, the injected--filename-in-msgagainst amake/localvalue in each form,--filename-in-msg=published.stanand--filename-in-msg published.stan, the call's winning and stanc seeing no straypublished.stanelement; andpedantic = TRUEagainst--warn-pedantic -fno-soa, with-fno-soastill reaching stanc - The
user_headerargument becomes the only channel for the user header.cpp_options[["USER_HEADER"]]andcpp_options[["user_header"]]are rejected with an error naming it. This deletes most ofresolve_user_header()(R/cpp_opts.R:189-245), which exists to reconcile the three spellings (both casings tracked positionally for make's last-wins rule, a four-level precedence chain, two conflict warnings) and whosepreviousparameter #1256 removes along with deferred compilation. Itssuppliedflag goes with them (#1254 §7, "ExplicitNULLmeans omission for all six, so one sentinel covers them"): the flag exists to give the argument precedence over the two spellings and otherwise to fall back toprevious, so with both goneuser_header = NULLand an omitteduser_headerare the same request.initialize()'s"user_header" %in% names(args)test (R/model.R:279) goes with it, and$compile()'smissing()check (:634) goes with$compile(). Add$user_header()so the dedicated argument has a dedicated accessor; without it$cpp_options()[["USER_HEADER"]]is the only way to read the header back. 14 test call sites use thecpp_optionsspelling. Breaking, needs a NEWS entry - Reject
allow-undefinedinstanc_options, matched on occurrence, with the same error as the header channel above, since it is the flaguser_headerimplies and not an independent setting. Pair it with the rule below so the escape hatch and the thing it escaped are not removed in one step - Reject
use-openclinstanc_options, matched on occurrence, namingcpp_options = list(stan_opencl = TRUE). It is the flagstan_openclimplies (R/model.R:676-678), and supplying it alone never produces an OpenCL-enabled executable. It produces one of two other things, chosen by the model. stanc emitsmatrix_clmembers only where a GLM-family function takes data it can move to the device, and those types exist only whenSTAN_OPENCLis defined, so such a model fails with six C++ template errors instead of one sentence. A model without such a call (bernoulli.stan, measured on 2.39) emits C++ identical but for the embeddedstancflagsstring, builds, and reportsSTAN_OPENCL=false. That is the worse outcome of the two, since nothing tells the caller their request did nothing - Reject
nameinstanc_options, matched on occurrence, with an error saying the model name comes from the file name. The one rejection with no argument to redirect to, sinceR/model.R:273takes the name from the file's basename and nothing else writes it (#1254 §3, "A flag cmdstanr derives from another argument is not separately settable"). It is half-wired today: a suppliednamereaches stanc but never touchesprivate$model_name_, sostanc_options = list(name = "foo")leaves$model_name()answeringbernoulliwhile every CSV the binary writes stampsfoo. Letting the option writemodel_name_would reconcile that and is the alternative considered. It is rejected because it earns nothing, since a model compiled from a string is named throughwrite_stan_file(basename =)(R/file.R:61) and two models that need telling apart in a CSV header need distinct file names anyway. This makes the injections atR/model.R:834and:1138unconditional; theoption_name != "name"quoting exception instanc_options_to_args()(:2611) stays, since it guards cmdstanr's own injected flag. One test uses the channel,test-model-compile.R:370-378, and asserts only that the flag is forwarded. Checked: brms and instantiate never mentionstanc_optionsat all, and rethinking'scstan()andulam()default it tolist("O1")and forward the caller's list, so a rethinking user reaches this the way they reach the other rejections. Breaking, covered by the consolidated NEWS entry - Delete
validate_cpp_options()(R/cpp_opts.R:151) and its tests (test-cpp_opts.R:24-37). It is dead code, called from nowhere inR/, but the reason to remove rather than adopt it is #1251: its one substantive behaviour warns that a logicalFALSEwill turn an option on, which #1251 reverses, so keeping it would document the opposite of v1.0's semantics. The new checks live inassert_valid_cpp_options()(#1250), pairing withassert_valid_stanc_options()(R/model.R:2562) - With no
stan_file, an explicitly supplied argument that can only be honoured by building or by reading the source is an error (#1254 §7):cpp_options,stanc_options,include_paths,user_header,force_recompile,pedantic.cpp_options,stanc_options,user_headerandforce_recompilecannot configure an artifact that will not be rebuilt, and a valid record is there to be inspected rather than overridden.include_pathsandpedanticfail on the source instead, and that reason belongs in their messages:include_pathsconfigures source resolution, needed by every stanc invocation whether or not anything compiles, whilepedanticasks for a stanc run over a program that is not there, so the guarantee that it reports on every call cannot be kept quietly. Check whether the argument was supplied, not what it resolves to: today's default isgetOption("cmdstanr_force_recompile")(R/model.R:621), so a check written asisTRUE(force_recompile)would error for every adoption performed by anyone with that option set, including everyinstantiatefit from inside a package the user never chose to look at. That default leaves the signature (#1254 §7, "No signature resolves thecmdstanr_force_recompileoption"), and that is three functions rather than two, sincecmdstanr_example()resolves it in its own signature atR/example.R:62and hands the answer tocmdstan_model(). Notmissing(): a wrapper's ownNULLdefault already makes itFALSEin the shared implementation, and the shared implementation is where the rebuild reason has to tell an explicitforce_recompile = TRUEfrom an option set in.Rprofilemonths ago. Document on the option's help page that it has no effect on executable-only models, so the advice arrives as documentation rather than as a runtime failure in somebody else's code. Breaking, needs a NEWS entry. Stage 1 reads the captured...by exact name, so an abbreviation such asforce = TRUEorcpp_opt = list()besideexe_filepasses the check and partial-matches on the build path (found in review of #1262). Left alone on purpose: removing$compile()makes these arguments the constructor's own formals, which closes the hole with no matching code, and the abbreviated spellings join that item's test matrix - Reject
warn-pedanticinstanc_options, matched on occurrence, with an error namingpedantic = TRUE. Two channels for one setting, and here they would differ in kind rather than in spelling:pedantic = TRUEis injected and not compared, so #1254 §8 reruns the check on an up-to-date model, while the same flag throughstanc_optionsis supplied and compared, so it warns only when a build happens. The namedFALSEhas a reason of its own on top of that: it emits nothing today whilepedantic = TRUEstill injects, so it reads as a way to switch pedantic off and is not one
Stage 2: schema and helper tests (merged to v1.0 in #1264, faa40abf)
- #1238: the record format, parser, writer and comparison helpers
- Replace the hand-enumerated
tests/testthat/resources/stan/.gitignorewith patterns, before anything writes a record beside a test model. It currently lists executable basenames (/bernoulli,/schools, …) so it will not match a record, and the leading dot hides the file fromlsbut not from git, sogit add -Acommits it silently. This is the trap #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_versionrather than to this cmdstanr: a validator that hardcodes one list turns every older record unreadable the first time a field is added, which is a mass rebuild by the back door (#1254 §6, "Unreadable means the record could not be accepted as a record at all"). Only one format version is live, so there is nothing to fixture here yet and this is a constraint on the validator's shape rather than a test. Checking only the fields the caller at hand happens to need is what leaves one record adoptable bycmdstan_model()in stage 4 and unavailable tostan_build_info()in stage 5, and stage 4 already consumes records, so this cannot wait for the stage that renders them. A record failing any check is unreadable whole: no part of it used, no part reported, including aformat_versionthat parsed. Three fixtures, and the two after the first are the ones an implementation guided by it will get wrong: invalid JSON; a record whoseformat_versionis present and supported but which carries one required field of the wrong type; and a record whoseformat_versionis one this cmdstanr does not read, carrying a body that is invalid under the current schema. Assert that none of the three reaches a comparison, that the first two yield noformat_version, and that the third isunsupported_formatand reports its version. The third fixture is what pins the order of the two steps (#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 leavesunsupported_formatunreachable for any record that differs from the current schema - Tri-state round-trip tests, distinct from the helper tests above because they check the format rather than the helpers. #1254 §1 encodes
reported_featuresby presence (a key is written only when the state is known) because the obviousNA-for-unknown encoding does not survive:jsonlitewritesNAasnulland reads it back asNULL, so the R type is gone after one trip through a file andis.na()returnslogical(0), which errors in anif. The two states stay recoverable throughnames(), but not through the access anyone writes:x[["k"]]isNULLeither way and!isTRUE(x)isTRUEeither way, so unknown and disabled collapse. Assert that known-enabled, known-disabled and unknown survive a write/read cycle as three distinguishable outcomes, and that nonullever appears in a written record. Assert the validator's side of it too, since the round trip passes whatever the validator does: a record whosereported_featuresomits a flag is readable, not rejected, because requiring today's four would make every record from a CmdStan that stopped reporting one unreadable, rebuilding every model it built that has a source and stripping the provenance from every one that does not (#1254 §4, "reported_featuresis checked for shape and never for membership").STAN_CPP_OPTIMSmoving is the precedent
Behaviour-free: nothing writes a record beside a user's program until stage 3. The open questions here are all now answered in #1254. The record is a hidden JSON file, .<exe>.cmdstanr.json, written beside the executable. Dependencies are identified by content, with each one's build-time path stored as built_from for provenance and not compared, so moving a project does not rebuild it. The user header's path is compared as well as its content, as one instance of a general rule: directories supplied to cmdstanr for C++ include resolution are compared as spellings, since the C++ closure beneath them cannot be enumerated, the -I flags in cpp_options being the rule's other instance. Includes are compared as an ordered sequence rather than a set. And the record's lifecycle follows the executable's, so whatever ignores the binary ignores the record.
Stage 3: transactional record writing (merged to v1.0 in #1265, 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_suppliedandstanc_options_injected, none of which is computable while injections land in the caller's list.reported_featurespasses and is captured here even though the live behaviour that consumes it is stage 4, which is worth stating so the capture does not look deferred too. The rest,stanc_name,include_paths, thedependenciesfields (the user header among them),artifact,builderandformat_version, are computable today;stanc_nameis the effective name, which the merged list already holds, so it needs the split for ordering rather than for correctness.tbb_diris the one row left over, and it is computable here too, from the call's owncpp_optionsrather than from anything the executable reports, which is why it has its own item below rather than a place in this list - Stop merging the injections into the user's list. Moved forward from stage 4: the record cannot be written correctly without it.
R/model.R:673,:677,:693and:835all write into the samestanc_optionsvariable, so by the time the writer runs the user's entries and cmdstanr's are indistinguishable.cpp_optionsneeds no accumulator, but it does need a deletion, and it is the worse bug::709writes the resolved user header back in under whichever spelling was used, and:941stores that list, so$cpp_options()today reportsUSER_HEADER = "/abs/path/inc/mine.hpp"for a caller who passeduser_header = "inc/mine.hpp"as an argument and never touchedcpp_options. That one does not add an entry beside the caller's, it replaces the caller's value with a resolved,wsl_safe_path()-transformed absolute path, socpp_options_suppliedread off that variable is wrong even with no concept of injection at all.USER_HEADER=still has to reachmake(make/program:41) and does so as a flag built with the others, not as a recordedcpp_optionsentry, since recording it would hold the header's path inrequestas well as independencies, and under WSL not even in the same spelling (#1254 §3, "It reaches it as a flag built with the others").resolved_header$spellingdies with:709, its only consumer. With:709gone,cpp_optionsholds exactly what the caller passed, which is why there is nocpp_options_injectedfield to build and the accumulator work below isstanc_optionsonly. Stage 3 would then have to either put the merged list into_supplied, which silently makes every injection a compared option and makes togglingpedanticrecompile, or reconstruct the split by subtraction, which is the reconstruct-after-the-fact fragility #1254 §4, "Origin is stored, not inferred", rejects by name. Accumulate injections into their own list and merge only when converting to arguments, so_suppliedand_injectedare both values the code already holds. Do not solve it with a snapshot taken before the first injection site; that works until someone adds a fifth one above it. Behaviour-free on its own - Record
request.stanc_name, the--namestanc receives, as a separate compared field as well as its place instanc_options_injected: the injected list records who asked, this records what stanc got, and only the second is compared (#1254 §4). Withnamerejected fromstanc_optionsin stage 1, the derived value is the only one there is. It rides on the item above, sinceR/model.R:835is one of the four sites the accumulator splits, but it is separate because it is compared and the injected list is not. Without it, moving a source, its executable and its record together under a new name changes nothing compared (content hash, artifact hash and builder all match, supplied options are empty on both sides), so the binary is reused while$model_name()and the name compiled into it disagree. That contradiction is visible inside R, not just in the CSV text:R/csv.R:873maps the CSV header ontofit$metadata()$model_name. What the comparison buys is where the CSV boundary falls, not avoiding one: uncompared, the stamp changes on whatever unrelated rebuild comes next andcheck_csv_metadata_matches()(:948-951) rejects the mixture then. The field is namedstanc_namerather thanmodel_nameso that it cannot be read as a second answer tomod$model_name(), which returns the same name without the suffix. Record the value as passed, including the_modelsuffixR/model.R:835appends. Not because it is what CmdStan stamps, which it is not: stanc mangles characters that cannot appear in a C++ identifier, and by hex escape rather than substitution. Measured identical at 2.35, 2.36 and 2.39:my-model_modelcompiles tomy_model_model,my.model_modeltomyx46model_model,my+model_modeltomyx43model_model. Record what is passed, because that is what both sides of the comparison compute the same way, and because reproducing a compiler's mangling in R would drift silently in the direction that does not rebuild. Assert it with amy-model.stanfixture, whose record must hold the rawmy-model_model: an implementation that reads the name back off the built binary, or normalises it on the way in, passes every fixture whose name is already a legal identifier. Two consequences are accepted rather than fixed: a punctuation-only rename rebuilds although the compiled name is unchanged, the artifact differing only in the raw string CmdStan writes onto astancflagsline in each sampler output CSV, which cmdstanr does not parse; and$model_name()still will not match the compiled name for a mangled file, which is true today. The comparison itself belongs to stage 3b's decision table. No NEWS entry of its own, since it is covered by the consolidated rebuild-feature entry below - #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 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 #1254 §6, "The field isknown_untracked_dependencies, notprovenance_complete", spends a subsection prohibiting. The regexes are the two in #1254 §6, "Provenance we cannot complete", which now include GNU Make'ssincludespelling. It is a fixed keyword, so it costs one alternation and no Make parsing. Test positive and negative detection for both, withsincludeamong the positivemake/localcases, and that the note fires on a successful write and not otherwise. Displaying the field throughstan_build_info()stays in stage 5 - Record
tbb_dir, the absolute TBB directory the call named (#1254 §4, thetbb_dirrow). Read the call'scpp_optionsasmakereceives them, last assignment wins andFALSEis an empty one, and take the first non-empty ofTBB_LIB,TBB_BINand the installation's ownlib/tbb, which is the order the makefile links in. Resolve a relative directory against the installation, wheremakeruns. Not asked ofmake: aTBB_LIBorTBB_BINfrommake/local,~/.config/stan/make.localor the environment moves the linked TBB and not the field, so such a build launches on Windows with the installation's TBB first, as today; the query was tried and dropped because CmdStan'sprint-%echoes through a shell that misspells Windows paths. #1261 is the consumer. Tests: a default-layout build recording the installation's ownlib/tbb; a build withcpp_options = list(tbb_lib =)recording that directory and not the default; a relativeTBB_LIBrecorded absolute; and the helper on a repeatedTBB_LIB, aFALSEone withTBB_BINbeside it, and aFALSEone alone - Document the record's lifecycle where users will look for it (#1254 §4, "In practice that means
.gitignore"). The rule is one line: whatever ignores the executable ignores the record, and wherever the executable goes the record goes with it. It needs three concrete cases. Add.*.cmdstanr.jsonbeside whatever already excludes the binary in.gitignore. Do the same in.Rbuildignore, which is the easier miss:R CMD buildexcludes hidden files by a fixed 28-entry list (tools:::.hidden_file_exclusions) that does not include this name and does not match on leading dot, so a package author who compiles in a source tree ships records describing their own machine. And any staging step that copies the executable copies both: CI artifacts, container layers, shared build directories. Home isvignettes/cmdstanr-internals.Rmdin the Compilation section beside "Executable location", which is already where the vignette says where the binary goes. Lands with the writer: before this stage there is no record to ignore, after it every compiled model has one
Stage 3b: the assessment engine, pure and unwired (merged to v1.0 in #1269, 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
builderpath 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:builderis compared as a normalized path and a version, and an implementer who adds an existence check turns a reported condition into a rebuild reason. The live call on this fixture does not reach the engine at all: the installation that is gone is also the selected one, so a source-backed model errors before assessment (#1254 §6, "A selected installation that is gone is its own error, checked where it is used"), which stage 4 tests separately. Pair it with the same record under a different, existing selected installation, which must rebuild onbuilderdiffering, which is the ordinary row, and not on the absence. No CmdStan installation is needed for either: the engine takes expected and observed and reads no disk of its own, and a path that does not exist is the cheapest fixture there is - A builder mismatch as a fixture, asserting the reason list and not only the verdict. Re-resolution is skipped exactly when the selected installation differs from
builder, so this fixture'sobservedcarries an unresolved dependency set rather than hashes. It must return rebuild naming the builder and saying nothing about the dependencies (#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, "--nameis compared as its own row because the build bakes it into the binary and nothing else compared pins it down"). Every other fixture changes a field some other row already covers, so a decision table that never grew astanc_namerow passes all of them green. Add the punctuation-only rename beside it,my-model.stantomy_model.stan, which must also rebuild even though both compile tomy_model_model, so the over-rebuild #1254 §4 accepts is pinned as intended rather than left for someone to normalise away - A replaced executable, which is the one fixture here that tests an argument rather than a row.
expectedcarries artifact hash H1, whileobservedcarries a binary hashing to H2 beside a record whoseartifactis H2: a pair on disk that is self-consistent, with every other compared field matching. It must return rebuild (#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_fromunderexact/and hash H, againstobservedholding the same hash H underapprox/, with every other field matching, must return rebuild naming the user header. Content-only identity passes every other fixture in this stage and fails this one. The live form is the pair of byte-identical headers in #1254 §6 whoseodds_impl.hppdiffer, givingmean(odds)of 0.3261 and -1.0 from the same compared fields; build that only if an end-to-end version is wanted, since the fixture needs no compiler - First, enumerate every cmdstanr argument that becomes a stanc or cpp option. The decision table's central rule is that user-supplied options are compared and injected ones are not, with
stanc_namethe one injected value compared in its own right (#1254 §4, "An injection nothing compares still applies"), so the table cannot be written correctly against an unknown injection set.R/model.R:672-693is where the injections happen, but the audit is the argument-to-option mapping rather than the mutation sites.pedantic = TRUEbecoming--warn-pedanticis the one that was missed entirely through five review rounds, and it was found by accident. This is the defect class tests do not reach: a rule nobody wrote down is not a rule any test enforces. About an hour of reading The audit found every injection compared through its cause or deliberately uncompared and changed no code. - Stanc option comparison needs both directions pinned: the one collapse and the two rebuilds. The comparison is on the argument vector the options emit, in the order given (#1254 §4, "Order is preserved, so a reordered list rebuilds"). Emitting is what collapses spelling, so
list("O1")andlist("O1" = TRUE)must not rebuild each other, which is the assertion that fails if the implementation compares the R list. The two rebuild cases have separate jobs.list("O1")against a record built withlist("O0")must rebuild, and fails against an implementation that never compares stanc options at all.list("O1", "warn-uninitialized")against a record built withlist("warn-uninitialized", "O1")must rebuild too, and that one fails against an implementation that sorts the vector before comparing it. Use flags that survive stage 1's rejections, which rules outwarn-pedantic,allow-undefined,use-opencl,nameandinclude-pathsas fixture material. The order case earns a fixture rather than only a paragraph because it is the one that reads as a bug: measured on 2.39.0,--O1 --O0and--O0 --O1both generate the code plain--O0does and differ only in thestancflagsstring stanc embeds. The fixture pair is identical for a plainer reason,--warn-uninitializedchanging no generated code at all, so either way the rebuild the assertion pins produces the same C++ twice. #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 acpp_optionsmismatch 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 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 #1235 added), wire$cpp_options()tocpp_options_supplied, and carryreported_featuresas the tri-state §1 defines (known enabled, known disabled, unknown), with absence never collapsed to disabled. Then moveassert_valid_threads()(R/cpp_opts.R:282) andassert_valid_opencl()(:271) onto it at all twelve sampling entry points: requesting a feature the binary reports disabled or unknown is an error, replacing today's warn-and-discard, which is the silently single-threaded four-hour run; and the converse error for a threading-enabled binary run withoutthreads_per_chain(:297-303) is removed, since an artifact exceeding the request is not a mismatch. Removing that error exposes a leak: the four launch sites (R/run.R:478,:545,:591,:648) writeSTAN_NUM_THREADSinto the R session when threads are supplied, CmdStan reads it as the default whennum_threadsis absent, so an omitted call inherits the previous call's count. Scope the variable to the child throughprocessx::process$new(env = ), whichwsl_compatible_process_new()forwards, set when supplied and absent otherwise, and move theWSLENVexport with it (#1254 §1, "Removing the error exposes a leak, and the count moves to the child process"). Notnum_threads=on the command line: CmdStan refuses to start when it disagrees with a setSTAN_NUM_THREADS. Tests: a consecutive-call test,threads_per_chain = 4then an omitted call, assertingmetadata()$threads_per_chainis 1 on the second; the thirteenSys.getenv("STAN_NUM_THREADS")assertions intest-threads.Rbecome one that the session variable is unchanged by a run. The two halves have a required order and it is easy to get backwards. Validators may move toreported_featuresbefore the merge is deleted, since the merge is then redundant, but deleting the merge first re-breaks #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 #765 again. And one must be info-backed, because a record is not unknown's only source: a mocked<exe> inforeturning a valid version withSTAN_THREADSabsent must construct with threading unknown and error onthreads_per_chain = 2the same way. Adoption admits an executable on a valid version rather than on a full flag set (#1254 §1, "Unknown is not expected from a supported binary, but it is possible"), so this path is reachable and nothing else here covers it. An implementation satisfying only the record-backed case can still read a missing key asFALSEon the live path. The fixture is nearly free: stage 4 already builds a fabricated hash-bound record for thebuildertest. Adoption sources the same accessor from the record rather than the call, per the item below. Needs its own NEWS entry for the validator change:threads_per_chainagainst a non-threaded build now errors where it warned, and the built-with-threading-but-not-using-it error is gone - Record-aware adoption (#1254 §7, "Executable plus a valid hash-bound record"). Split adoption out of
initialize()first: it is currently the intersection!is.null(exe_file) && is.null(stan_file), re-derived at each site (R/model.R:302,:320), which is also whyexe_filemeans both "existing binary" and "planned destination" (#1253). With §7 forbidding build configuration here and #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 #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 (#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 unparseablebuilderversion 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> infomust report complete version fields. Syntactic only, since rejecting a version for being old would defeat §7, whose point is that binaries built by older CmdStan keep working. Without itmodel_compile_info()synthesises".."from three absent fields (R/cpp_opts.R:68), which passes every guardcmdstan_version_compare()has, so construction succeeds and the failure surfaces later inside a version gate as aTRUE/FALSEcomplaint. Test aninforesult missing the version fields and one printing a malformed value. Unusable record (missing, unreadable, hash mismatch, unsupported format, or an unparseablebuilderversion, which #1254 §4 makes a field-check failure like any other): fall back to<exe> info. If it reports a valid version, construct silently with unavailable provenance, together with thereported_featuresthe binary supplies and$cpp_options()empty, never an invented request. If it does not, error. So three outcomes, not two (#1254 §7, "Executable-only models are kept, and adoption has three outcomes"), and only the first two permit fitting and skip the automatic rebuild. Drop the unusedversionparameter frommodel_compile_info()(R/cpp_opts.R:52) while rewriting its callers: three call sites passself$cmdstan_version()into a body that never mentions it, which reads as though the version participates. Tests: the counting mock from #1235 extended with a zero-query row for valid-record adoption, which is the only thing separating the design's cost from an implementation that reads the record and spawns the process anyway; a fabricated record withbuilderat 2.35 under a 2.39 session asserting$cmdstan_version()reports 2.35, which needs no second CmdStan installation; missing, corrupt, unsupported-version and hash-mismatched records each falling back correctly; and$cpp_options()empty versus recorded across the two cases. Needs a NEWS entry, and not the one the no-launch rule reads like: launching was already best-effort, sincerun_info_cli()passeserror_on_status = FALSE(R/cpp_opts.R:17), so a binary that cannot run constructs successfully today too. Measured: a six-byte file with the execute bit exits 126 and yields a model whose$cmdstan_version()answers the session's 2.39.0, while the same file without the bit dies on a rawprocessx_execerror, which is #1246. The two "cannot run" paths disagree today, and this replaces both. What changes is the unusable-record path: adoption now errors, because neither the record nor<exe> infoyields a syntactically valid version, where today construction succeeds andR/model.R:318attributes the session's CmdStan version to a binary cmdstanr never spoke to. Nothing that would have sampled is refused (#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
builderguarantees it fires"). Adopt an executable whose valid record names abuilderother than the selected installation, and assert that construction succeeds, nothing is compiled, and a guarded method runs. Beside it, the same record under a source-backed construction must rebuild onbuilder, which is what shows the difference is the missing source rather than the engine. The fabricated-builderrecord from the$cmdstan_version()test above is the fixture, so no second installation is needed. This is the Monday-to-Wednesday case in #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 withCmdStanModel$public_methodsand$public_fieldsand fail on any member without a classification. Compare against the table's non-removed method rows and assert$compile()'s absence separately. Do not write the assertion against a count: the table is the union of today's surface and 1.0's, so it carries one method row more than 1.0 has members, and a test keyed on the number of rows fails at 1.0 against its own table. #1254 §5 does that arithmetic and is where the numbers belong; copying them here is how the schema-row count went stale last round. Then exercise every member, not one per class: a representative passing says nothing about the other guarded methods, each of which can be classified correctly here and still run a stale executable. Call each guarded method with no other arguments against a stale model, so a method that validates late fails with a missing-argument complaint instead of the staleness error and the matrix checks ordering rather than only presence; none of them get far enough to need MPI, data or an algorithm. The must-nots take the opposite assertion, not the same one. A non-guarded method has no obligation to succeed bare ($save_hpp_file()wants a destination,$expose_functions()wants Rcpp), so requiring the bare call to pass would fail on argument handling while claiming to test staleness. Assert instead that whatever it raises is not the staleness error. The matrix is then exact, and exhaustive by construction rather than by adding up: every guarded method called bare and asserted to raise it; every non-guarded method called bare and asserted not to;$initialize()classified but never invoked (calling it on a live object retargets private state); thefunctionspublic field inspected rather than called, and likewise asserted not to raise it;$compile()asserted absent. A member added later joins the matrix instead of breaking a total. Give the staleness error a condition class in this stage. Most of this matrix is negative assertions, and a negative assertion matched on message text passes forever the moment the message is reworded.$clone()is additionally asserted not to error, and$expose_functions()needs an explicit skip where Rcpp exposure is unavailable, since a silent skip drops a guarded member from the matrix without the enumeration noticing. §5's own justification for listing$initialize()and$clone()is that an unlisted member is indistinguishable from an overlooked one, which is a property a test can hold and a review cannot - The replaced-executable regression, end to end, which stage 3b's fixture cannot reach. Construct object A from a source; rebuild the same executable path through a second object B, so the executable and the record beside it are again a matching, self-consistent pair; then assert that a guarded operation on A errors. Stage 3b proves the engine reads an expected artifact hash, and this proves the object carries one: an implementation that fills
expectedfrom the record found on disk passes stage 3b's whole decision table and fails only here (#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 whattar -x,cp -pand a backup restore leave behind.cmdstan_model()must rebuild naming the Stan program, and a guarded method on the already-constructed object must error. The engine compares hashes and cannot see a timestamp, so this is not a stage 3b fixture; it pins that no caller has fallen back tofile.mtime(), asR/model.R:732-733does today, since such an implementation passes every test whose edit is newer than its binary and fails only here - A failed re-resolution is an error carrying stanc's message, at the constructor and at every guarded method, and nothing rebuilds (#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.ymlentries for the standalone family (compile_stan_file,format_stan_file,check_syntax_stan_file,variables_stan_file) and removal of any topic the same pull request deletes. The reference index is an explicitcontents:list and pkgdown errors on topics missing from it, so.github/workflows/pkgdown.yamlfails on CI otherwise. Stage 5 carries the same item forstan_build_info. Outcome: no index change was needed, because each standalone function is documented in the topic of its method twin (compile_stan_file()undercmdstan_model, the other three undermodel-method-check_syntax,-formatand-variables), and the index already lists those - Remove
compile_model_methodsandcompile_standalone, in the #1256 pull request (#1254 §8). Neither is build configuration: they runexpose_stan_functions()andexpose_model_methods()after make finishes (R/model.R:963,:966) and change no make flag and no byte of the executable, which is why neither appears incompile_impl()'s signature. Both are already dropped in silence whenever the executable is current, because$compile()returns at:804and the exposures sit past it, so the same call populatesfunctionsor not depending on whether a rebuild happened to be needed. The replacements are the ones their own roxygen already recommends:fit$init_model_methods()(:551) and$expose_functions()(:556).fit$init_model_methods()fails on the same reuse path today, since the model C++ it compiles from is generated only inside the build branch (R/model.R:848) and the fit copies an empty environment;test-model-methods.R:108asserts that error. #1254 §5 puts that C++ in the construction snapshot on both paths ("The model's generated C++ is part of the snapshot, for the same reason"): the sameget_standalone_hpp()call, made before the build-or-reuse branch instead of inside it, written to a tempfile for$hpp_file()as the build branch does today.test-model-methods.R:108flips to expecting success. One combination test: construct on a current executable,$sample(),init_model_methods(),log_prob()returns a finite value. Drop the roxygen caveats that$hpp_file()errors when an executable was reused (R/model.R:446-448,:478-480).$expose_functions()must be fixed in the same pull request, because removal makes it the only route and it fails on the same path:expose_stan_functions()refuses whenfunction_env$existing_exeisTRUE(R/utils.R:1217), and:267,:299and:786together leave itTRUEfor a source-backed model whose executable is up to date, socmdstan_model("m.stan")followed by$expose_functions()errors "Exporting standalone functions is not possible with a pre-compiled Stan model!" about a model that has a source. Makeexisting_exemean "this model has no source", and generate the hpp on demand from the registered source. 16 test references to update, acrosstest-model-expose-functions.R,test-model-methods.Randtest-fit-shared.R. Breaking, needs a NEWS entry and migration text, and the migration text has to name the pass-through:brm(stan_model_args = list(...))and instantiate's...both forward tocmdstan_model(), so scripts break through packages that never mention either argument - #1245: moved here from "independent" by the design review, which is right that this stage subsumes it. Its discriminator dissolves:
hpp_codeoff the internal build call is the model's generated C++ on both paths, so a source-backed model always has it and an executable-only model never does (#1254 §8, §5), which answers the "is there generated C++?" half the issue asks for, and the other half stops existing oncecompile = FALSEand publicdry_rungo, since a model object with no executable becomes unconstructable. The guard belongs to the item above: #1254 §8, "$expose_functions()is fixed here too, since removal makes it the only route". What is left over is theexisting_exetohas_generated_cpprename across 13 sites, and it should not land first: the issue plans the rename and the guard fix as one edit because the field's only read sits directly above the message, and this stage changes that read to a different question, whether the model has a source. Its user-visible half is reachable today on the ordinary path and could ship earlier as a plain bug fix: measured on 2.39.0,cmdstan_model(f)twice followed by$expose_functions()errors with "not possible with a pre-compiled Stan model" on a model whose source is beside it, nocompile = FALSEinvolved. That buys earliness rather than correctness, since this stage replaces it with generating the hpp on demand. Outcome:existing_exeis gone rather than renamed;expose_stan_functions()asks whether the model carries generated C++ (hpp_code), and a model built from a Stan file always does - The selected-installation check, immediately before
makeor a tool is invoked out of it rather than as a general precondition (#1254 §6, "A selected installation that is gone is its own error, checked where it is used").makeruns in the selected installation (R/model.R:862-866) and so does stanc, whichstanc_cmd()names relatively asbin/stanc(R/utils.R:123-129): the build, the re-resolution an assessment needs whenever the selection is also the recorded builder, the construction-timestanc --info(R/model.R:2673) that §5's snapshot is taken from, and$check_syntax()(:1151) and$format()(:1278) with their standalone twins, which conduct no assessment and reach it anyway. It reaches past the model object as well:fit$cmdstan_summary()andfit$cmdstan_diagnose()runbin/stansummaryandbin/diagnosewith the selected installation as their working directory (run_cmdstan_tool(),R/run.R:316) and build them on demand with amakein that same tree (check_target_exe(),:414). Both live onCmdStanFit, so a check writte
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- 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