Skip to content

Commit 6d634fc

Browse files
committed
Resolve the user header through one shared precedence rule
The header precedence lived in a four-branch chain inside compile() and was insufficient in two ways. cpp_options may already have been repopulated from precompile_cpp_options_ by the time it ran, so an explicit user_header = NULL still selected an inherited USER_HEADER; and cmdstan_model(compile = FALSE) never enters compile() at all, so constructing a model with an explicit NULL alongside a cpp_options header silently kept the header and built with it. The precedence is now a small pure resolver called from both initialize() and compile(). An explicit non-NULL argument wins; an explicit NULL clears both cpp_options spellings; only an omitted argument consults cpp_options and then the stored header. Supplied-ness is captured before anything is reassigned, since user_header = NULL is also the default and cannot otherwise be told from an omitted argument -- with missing() in compile() and, for arguments arriving through ..., with names(), which list(...) preserves for NULL entries. Warnings are emitted at the call sites so a model compiled at construction warns once rather than twice. Both spellings are reduced to the one actually used, so $cpp_options() no longer reports the ignored duplicate. A header changing identity now forces compilation, through a dirty flag rather than by inferring it from cpp_options_: a compile through the lowercase spelling never leaves USER_HEADER behind, stored options are WSL-safe paths while user_header is deliberately a host path, and an absent entry conflates "no header" with "unknown". The flag is latched rather than assigned, because on a bare retry after a failed compile the reuse branch resolves back to the same header and nothing looks changed. It is cleared only by a successful executable replacement. user_header_ and using_user_header_ are configuration for the next invocation rather than a description of the executable, so they are assigned as soon as they are validated. A failed compile with a new header is usually a bug in that header, and a bare retry after fixing it must build the header the user supplied. This also stops a failed compile from leaving using_user_header_ FALSE, which made $check_syntax() report the bogus "declared without specifying a definition" error again. Shape is validated wherever a header is accepted, so character(0) is rejected informatively, while existence is checked only when compiling, keeping a header created between construction and $compile() working.
1 parent 5f04a7d commit 6d634fc

3 files changed

Lines changed: 361 additions & 69 deletions

File tree

R/cpp_opts.R

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,80 @@ validate_cpp_options <- function(cpp_options) {
144144
cpp_options
145145
}
146146

147+
# user headers ---------------------------------------------------------
148+
# Decide which user header a compilation should use and reduce cpp_options to a
149+
# single, unambiguous source for it.
150+
#
151+
# Precedence:
152+
# 1. an explicit non-NULL `user_header` argument;
153+
# 2. an explicit `user_header = NULL`, which clears any header carried in
154+
# cpp_options as well;
155+
# 3. only when the argument is omitted, cpp_options -- USER_HEADER ahead of
156+
# user_header whichever order they appear in -- and then `previous`, the
157+
# header the model already holds.
158+
#
159+
# `supplied` is what makes (2) expressible at all: `user_header = NULL` is also
160+
# the default, so the value alone cannot separate "cleared" from "not
161+
# mentioned". `cpp_options_supplied` separates a header passed in the same call,
162+
# a conflict worth warning about, from one inherited from an earlier call.
163+
#
164+
# Both spellings are always dropped from cpp_options; callers reinsert the
165+
# selected header under `spelling`, in whatever form they store. The header is
166+
# returned as supplied, neither made absolute nor WSL-safe, because callers
167+
# differ on which they need.
168+
resolve_user_header <- function(user_header,
169+
supplied,
170+
cpp_options,
171+
cpp_options_supplied = TRUE,
172+
previous = NULL) {
173+
from_upper <- cpp_options[["USER_HEADER"]]
174+
from_lower <- cpp_options[["user_header"]]
175+
conflict <- NULL
176+
spelling <- "USER_HEADER"
177+
178+
if (supplied) {
179+
if (cpp_options_supplied && (!is.null(from_upper) || !is.null(from_lower))) {
180+
conflict <- "argument"
181+
}
182+
header <- user_header
183+
} else if (!is.null(from_upper)) {
184+
if (!is.null(from_lower)) {
185+
conflict <- "cpp_options"
186+
}
187+
header <- from_upper
188+
} else if (!is.null(from_lower)) {
189+
header <- from_lower
190+
spelling <- "user_header"
191+
} else {
192+
header <- previous
193+
}
194+
195+
# Shape is checked wherever a header is accepted; whether it exists is checked
196+
# only when compiling, so that a header created between construction and
197+
# $compile() still works.
198+
if (!is.null(header)) {
199+
checkmate::assert_string(header, .var.name = "user_header")
200+
}
201+
cpp_options[["USER_HEADER"]] <- NULL
202+
cpp_options[["user_header"]] <- NULL
203+
204+
list(
205+
user_header = header,
206+
spelling = spelling,
207+
cpp_options = cpp_options,
208+
conflict = conflict
209+
)
210+
}
211+
212+
warn_user_header_conflict <- function(conflict) {
213+
if (identical(conflict, "argument")) {
214+
warning("User header specified both via user_header argument and via cpp_options arguments")
215+
} else if (identical(conflict, "cpp_options")) {
216+
warning('User header specified both via cpp_options[["USER_HEADER"]] and cpp_options[["user_header"]].', call. = FALSE)
217+
}
218+
invisible(NULL)
219+
}
220+
147221
# check specific options for validity ---------------------------------
148222
cpp_option_value <- function(cpp_options, option) {
149223
# CmdStanR input and executable metadata can use different casing. Prefer

R/model.R

Lines changed: 58 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -244,6 +244,10 @@ CmdStanModel <- R6::R6Class(
244244
include_paths_ = NULL,
245245
user_header_ = NULL,
246246
using_user_header_ = FALSE,
247+
# Neither configuration nor a description of the executable: a record that
248+
# the two have drifted apart. Set by a change of header, cleared only by a
249+
# successful executable replacement.
250+
user_header_dirty_ = FALSE,
247251
precompile_cpp_options_ = NULL,
248252
precompile_stanc_options_ = NULL,
249253
precompile_include_paths_ = NULL,
@@ -263,13 +267,26 @@ CmdStanModel <- R6::R6Class(
263267
private$stan_file_ <- resolve_path(stan_file)
264268
private$stan_code_ <- readLines(stan_file)
265269
private$model_name_ <- gsub(" ", "_", strip_ext(basename(private$stan_file_)))
266-
private$precompile_cpp_options_ <- args$cpp_options %||% list()
267270
private$precompile_stanc_options_ <- assert_valid_stanc_options(args$stanc_options) %||% list()
268-
if (!is.null(args$user_header) || !is.null(args$cpp_options[["USER_HEADER"]]) ||
269-
!is.null(args$cpp_options[["user_header"]])) {
270-
private$using_user_header_ <- TRUE
271+
# The same precedence $compile() applies, run here too: a model created
272+
# with compile = FALSE never enters $compile(), so resolving the paths
273+
# alone would let an explicit user_header = NULL be ignored. `...`
274+
# preserves NULL entries, so names() is the missing() equivalent here.
275+
resolved_header <- resolve_user_header(
276+
user_header = args$user_header,
277+
supplied = "user_header" %in% names(args),
278+
cpp_options = args$cpp_options %||% list()
279+
)
280+
if (!compile) {
281+
# Otherwise $compile() receives the original arguments below and warns
282+
# once; warning here as well would double up.
283+
warn_user_header_conflict(resolved_header$conflict)
271284
}
272-
private$user_header_ <- args$user_header
285+
private$precompile_cpp_options_ <- resolved_header$cpp_options
286+
# Prepopulating this also keeps the first $compile() from seeing a
287+
# change of header and rebuilding an already-current executable.
288+
private$user_header_ <- resolve_path(resolved_header$user_header)
289+
private$using_user_header_ <- !is.null(resolved_header$user_header)
273290
if (is.null(args$include_paths) && any(grepl("#include" , private$stan_code_))) {
274291
private$precompile_include_paths_ <- dirname(private$stan_file_)
275292
} else {
@@ -584,6 +601,11 @@ compile <- function(quiet = TRUE,
584601
)
585602
}
586603
assert_stan_file_exists(self$stan_file())
604+
# Captured before either is reassigned below: an explicit user_header = NULL
605+
# is indistinguishable from an omitted argument by value alone, and a header
606+
# inherited from an earlier call is not a conflict worth warning about.
607+
user_header_supplied <- !missing(user_header)
608+
cpp_options_supplied <- length(cpp_options) > 0
587609
if (length(cpp_options) == 0 && !is.null(private$precompile_cpp_options_)) {
588610
cpp_options <- private$precompile_cpp_options_
589611
}
@@ -617,41 +639,44 @@ compile <- function(quiet = TRUE,
617639
stanc_options[["use-opencl"]] <- TRUE
618640
}
619641

620-
# Note that unlike cpp_options["USER_HEADER"], the user_header variable is deliberately
621-
# not transformed with wsl_safe_path() as that breaks the check below on WSLv1
622-
if (!is.null(user_header)) {
623-
if (!is.null(cpp_options[["USER_HEADER"]]) || !is.null(cpp_options[["user_header"]])) {
624-
warning("User header specified both via user_header argument and via cpp_options arguments")
625-
}
626-
627-
cpp_options[["USER_HEADER"]] <- wsl_safe_path(absolute_path(user_header))
628-
} else if (!is.null(cpp_options[["USER_HEADER"]])) {
629-
if (!is.null(cpp_options[["user_header"]])) {
630-
warning('User header specified both via cpp_options[["USER_HEADER"]] and cpp_options[["user_header"]].', call. = FALSE)
631-
}
632-
633-
user_header <- cpp_options[["USER_HEADER"]]
634-
cpp_options[["USER_HEADER"]] <- wsl_safe_path(absolute_path(cpp_options[["USER_HEADER"]]))
635-
} else if (!is.null(cpp_options[["user_header"]])) {
636-
user_header <- cpp_options[["user_header"]]
637-
cpp_options[["user_header"]] <- wsl_safe_path(absolute_path(cpp_options[["user_header"]]))
638-
} else if (!is.null(private$user_header_)) {
642+
resolved_header <- resolve_user_header(
643+
user_header = user_header,
644+
supplied = user_header_supplied,
645+
cpp_options = cpp_options,
646+
cpp_options_supplied = cpp_options_supplied,
639647
# the header the model was last compiled with, or the one supplied to
640648
# cmdstan_model() if it has not been compiled yet
641-
user_header <- private$user_header_
642-
cpp_options[["USER_HEADER"]] <- wsl_safe_path(absolute_path(user_header))
643-
}
644-
649+
previous = private$user_header_
650+
)
651+
warn_user_header_conflict(resolved_header$conflict)
652+
user_header <- resolved_header$user_header
653+
cpp_options <- resolved_header$cpp_options
645654

646655
using_user_header <- !is.null(user_header)
647656
if (using_user_header) {
648657
stanc_options[["allow-undefined"]] <- TRUE
649-
user_header <- absolute_path(user_header) # As mentioned above, just absolute, not wsl_safe_path()
658+
# Note that unlike cpp_options["USER_HEADER"], the user_header variable is
659+
# deliberately not transformed with wsl_safe_path() as that breaks the check
660+
# below on WSLv1
661+
user_header <- resolve_path(user_header)
650662
if (!file.exists(user_header)) {
651663
stop(paste0("User header file '", user_header, "' does not exist."), call. = FALSE)
652664
}
665+
cpp_options[[resolved_header$spelling]] <- wsl_safe_path(user_header)
653666
}
654667

668+
# Source configuration, so assigned eagerly: it says what the next stanc or
669+
# make invocation should use, and a failed compile must not revert it. The
670+
# usual route to a failed compile with a new header is a bug in that header,
671+
# and a bare retry after fixing it has to build the header the user supplied.
672+
#
673+
# The divergence marker is latched rather than assigned, because on that retry
674+
# the reuse branch resolves back to the same header and nothing looks changed.
675+
private$user_header_dirty_ <- isTRUE(private$user_header_dirty_) ||
676+
!same_path(user_header, private$user_header_)
677+
private$user_header_ <- user_header
678+
private$using_user_header_ <- using_user_header
679+
655680
# The resolved destination need not be the executable this object describes,
656681
# e.g. $compile(dir = ) aimed at a directory that already holds a current
657682
# executable. Adopting that binary while keeping this object's generated C++
@@ -662,12 +687,15 @@ compile <- function(quiet = TRUE,
662687
# - the user forced compilation,
663688
# - the executable does not exist
664689
# - the destination is not the executable this object already describes
690+
# - the user header in use is not the one the executable was built against
665691
# - the stan model was changed since last compilation
666692
# - a user header is used and the user header changed since last compilation (#813)
667693
if (!file.exists(exe)) {
668694
force_recompile <- TRUE
669695
} else if (exe_changed) {
670696
force_recompile <- TRUE
697+
} else if (isTRUE(private$user_header_dirty_)) {
698+
force_recompile <- TRUE
671699
} else if (file.exists(self$stan_file())
672700
&& file.mtime(exe) < file.mtime(self$stan_file())) {
673701
force_recompile <- TRUE
@@ -831,8 +859,7 @@ compile <- function(quiet = TRUE,
831859
self$functions$existing_exe <- FALSE
832860
private$stan_code_ <- stan_code
833861
private$variables_ <- NULL
834-
private$using_user_header_ <- using_user_header
835-
private$user_header_ <- user_header
862+
private$user_header_dirty_ <- FALSE
836863
private$hpp_file_ <- hpp_file
837864
private$model_methods_env_ <- model_methods_env
838865
private$precompile_cpp_options_ <- NULL

0 commit comments

Comments
 (0)