diff --git a/R/core-functions.R b/R/core-functions.R index 3dfeb2e..0d887b9 100644 --- a/R/core-functions.R +++ b/R/core-functions.R @@ -200,8 +200,9 @@ fzl <- function(models = "*", calculators = "*", check = FALSE) { #' \code{"algorithms/montecarlo_uniform.py"}. #' @param calculators Calculator specification(s). Default \code{NULL}. When #' \code{model} is a function, this must be a single integer (default -#' \code{1L}), accepted for API compatibility (see "Direct function model" -#' below) — calls are always run sequentially regardless of its value. +#' \code{1L}) and is always forced to \code{1L} (see "Direct function +#' model" below): R functions are only safe to call from the main thread, +#' so a value other than \code{1} triggers a warning and is overridden. #' @param algorithm_options Algorithm options as a named list, a JSON string, #' or a path to a JSON file. Default \code{NULL}. #' @param analysis_dir Analysis directory. Default \code{"analysis"}. @@ -209,30 +210,28 @@ fzl <- function(models = "*", calculators = "*", check = FALSE) { #' @section Direct function model: #' Instead of a file-based model, \code{model} can be an R function (this #' requires the \code{main} branch of \code{fz} from GitHub, installed with -#' \code{fz_install(packages = "git+https://github.com/Funz/fz.git")} — this +#' \code{fz_install(packages = "git+https://github.com/Funz/fz.git")} -- this #' mode is not available in released PyPI versions of \code{funz-fz} yet). In #' this mode: #' \itemize{ -#' \item \code{input_path} must be \code{NULL} — there are no input files. +#' \item \code{input_path} must be \code{NULL} -- there are no input files. #' \item \code{input_variables} names must match the function's arguments. #' \item \code{output_expression} may be \code{NULL}; the value used is then #' the first element of the function's return value (its return value #' directly if scalar, the first element if a vector/list, or the first #' entry's value if a named list). -#' \item \code{calculators} must be a single integer, accepted for API -#' compatibility but currently without effect: on the \code{fz} side, -#' function-model calls always run sequentially, one at a time in the -#' calling thread — never through a thread pool. This is required -#' because R functions are called back into the R session via -#' \code{reticulate}, which is only safe from the main thread; running -#' a Python-side thread pool (which always dispatches to a worker thread, -#' even with a single worker) would call the function from a thread -#' other than the main one and crash the R session. This safety fix -#' requires \href{https://github.com/Funz/fz/pull/73}{Funz/fz#73} on the -#' \code{fz} \code{main} branch (not yet in a PyPI release as of -#' 2026-07-12); without it, direct function models crash regardless of -#' \code{calculators}. A value other than \code{1} emits a warning -#' noting that it has no effect. +#' \item \code{calculators} must be a single integer, but is always forced +#' to \code{1} here -- regardless of the value passed in -- before being +#' forwarded to \code{fz}. On the \code{fz} (Python) side, +#' \code{calculators > 1} now evaluates a Python-function model +#' concurrently in a worker-thread pool (\href{https://github.com/Funz/fz/pull/73}{Funz/fz#73}); +#' that is unsafe here because R functions are called back into the R +#' session via \code{reticulate}, which is only safe from the main +#' thread -- invoking the function from any other thread crashes the R +#' session. Passing a value other than \code{1} therefore emits a +#' warning explaining that it is being forced back to \code{1}, and the +#' call always proceeds with \code{calculators = 1} (strictly +#' sequential, one call at a time, in the calling thread). #' \item each iteration's directory (\code{iterNNN/}) only contains a #' \code{values.csv} of that iteration's function inputs/outputs, since #' there is no file-based execution. @@ -303,7 +302,7 @@ fzl <- function(models = "*", calculators = "*", check = FALSE) { #' model = rosenbrock, #' output_expression = "result", #' algorithm = "examples/algorithms/bfgs.py", -#' calculators = 4L, +#' calculators = 1L, # forced to 1L anyway for R functions -- see "Direct function model" #' algorithm_options = list(max_iter = 20, tol = 1e-4) #' ) #' } @@ -322,12 +321,16 @@ fzd <- function(input_path, input_variables, model, output_expression = NULL, al calculators <- as.integer(calculators) if (calculators != 1L) { warning( - "calculators = ", calculators, " has no effect when 'model' is an R ", - "function: evaluations always run sequentially (one call at a time), ", - "since R functions bridged in via reticulate are only safe to call ", - "from the main thread.", + "calculators = ", calculators, " was requested but is forced to 1 ", + "when 'model' is an R function: on the fz (Python) side, calculators > 1 ", + "now evaluates the model concurrently in a worker-thread pool (see ", + "https://github.com/Funz/fz#function-models), but R functions bridged in ", + "via reticulate are only safe to call from the main thread -- calling them ", + "from any other thread crashes the R session. calculators is therefore ", + "always reset to 1 here, regardless of the requested value.", call. = FALSE ) + calculators <- 1L } } fz_module <- get_fz() diff --git a/R/install.R b/R/install.R index 08f40e1..b099793 100644 --- a/R/install.R +++ b/R/install.R @@ -68,7 +68,7 @@ fz_available <- function() { #' @param global Logical; install system-wide instead of user-level. #' Default \code{FALSE}. #' -#' @return Named list with installation details (path, id, …). +#' @return Named list with installation details (path, id, ...). #' @export #' #' @examples @@ -93,7 +93,7 @@ install_model <- function(source, global = FALSE) { #' @param global Logical; install system-wide instead of user-level. #' Default \code{FALSE}. #' -#' @return Named list with installation details (path, name, …). +#' @return Named list with installation details (path, name, ...). #' @export #' #' @examples diff --git a/R/zzz.R b/R/zzz.R index c8f1c27..87262b7 100644 --- a/R/zzz.R +++ b/R/zzz.R @@ -3,7 +3,7 @@ .pkg$fz_available <- NULL .onLoad <- function(libname, pkgname) { - # Python is not initialised at load time — deferred to first use via get_fz(). + # Python is not initialised at load time -- deferred to first use via get_fz(). } #' @keywords internal diff --git a/man/fzd.Rd b/man/fzd.Rd index 209e27d..792fa32 100644 --- a/man/fzd.Rd +++ b/man/fzd.Rd @@ -36,8 +36,9 @@ value is used.} \item{calculators}{Calculator specification(s). Default \code{NULL}. When \code{model} is a function, this must be a single integer (default -\code{1L}), accepted for API compatibility (see "Direct function model" -below) — calls are always run sequentially regardless of its value.} +\code{1L}) and is always forced to \code{1L} (see "Direct function +model" below): R functions are only safe to call from the main thread, +so a value other than \code{1} triggers a warning and is overridden.} \item{algorithm_options}{Algorithm options as a named list, a JSON string, or a path to a JSON file. Default \code{NULL}.} @@ -57,30 +58,28 @@ is useful for sensitivity analysis, surrogate-model fitting, or optimization. Instead of a file-based model, \code{model} can be an R function (this requires the \code{main} branch of \code{fz} from GitHub, installed with -\code{fz_install(packages = "git+https://github.com/Funz/fz.git")} — this +\code{fz_install(packages = "git+https://github.com/Funz/fz.git")} -- this mode is not available in released PyPI versions of \code{funz-fz} yet). In this mode: \itemize{ -\item \code{input_path} must be \code{NULL} — there are no input files. +\item \code{input_path} must be \code{NULL} -- there are no input files. \item \code{input_variables} names must match the function's arguments. \item \code{output_expression} may be \code{NULL}; the value used is then the first element of the function's return value (its return value directly if scalar, the first element if a vector/list, or the first entry's value if a named list). -\item \code{calculators} must be a single integer, accepted for API -compatibility but currently without effect: on the \code{fz} side, -function-model calls always run sequentially, one at a time in the -calling thread — never through a thread pool. This is required -because R functions are called back into the R session via -\code{reticulate}, which is only safe from the main thread; running -a Python-side thread pool (which always dispatches to a worker thread, -even with a single worker) would call the function from a thread -other than the main one and crash the R session. This safety fix -requires \href{https://github.com/Funz/fz/pull/73}{Funz/fz#73} on the -\code{fz} \code{main} branch (not yet in a PyPI release as of -2026-07-12); without it, direct function models crash regardless of -\code{calculators}. A value other than \code{1} emits a warning -noting that it has no effect. +\item \code{calculators} must be a single integer, but is always forced +to \code{1} here -- regardless of the value passed in -- before being +forwarded to \code{fz}. On the \code{fz} (Python) side, +\code{calculators > 1} now evaluates a Python-function model +concurrently in a worker-thread pool (\href{https://github.com/Funz/fz/pull/73}{Funz/fz#73}); +that is unsafe here because R functions are called back into the R +session via \code{reticulate}, which is only safe from the main +thread -- invoking the function from any other thread crashes the R +session. Passing a value other than \code{1} therefore emits a +warning explaining that it is being forced back to \code{1}, and the +call always proceeds with \code{calculators = 1} (strictly +sequential, one call at a time, in the calling thread). \item each iteration's directory (\code{iterNNN/}) only contains a \code{values.csv} of that iteration's function inputs/outputs, since there is no file-based execution. @@ -149,7 +148,7 @@ result <- fzd( model = rosenbrock, output_expression = "result", algorithm = "examples/algorithms/bfgs.py", - calculators = 4L, + calculators = 1L, # forced to 1L anyway for R functions -- see "Direct function model" algorithm_options = list(max_iter = 20, tol = 1e-4) ) } diff --git a/man/install_algorithm.Rd b/man/install_algorithm.Rd index 229782d..7900505 100644 --- a/man/install_algorithm.Rd +++ b/man/install_algorithm.Rd @@ -14,7 +14,7 @@ or path to a local zip file.} Default \code{FALSE}.} } \value{ -Named list with installation details (path, name, …). +Named list with installation details (path, name, ...). } \description{ Installs an algorithm from a GitHub repository name, URL, or local zip file diff --git a/man/install_model.Rd b/man/install_model.Rd index 3680350..296ba1e 100644 --- a/man/install_model.Rd +++ b/man/install_model.Rd @@ -14,7 +14,7 @@ path to a local zip file.} Default \code{FALSE}.} } \value{ -Named list with installation details (path, id, …). +Named list with installation details (path, id, ...). } \description{ Installs a model from a GitHub repository name, URL, or local zip file into diff --git a/tests/testthat/test-fzd-calculators-forced.R b/tests/testthat/test-fzd-calculators-forced.R new file mode 100644 index 0000000..b448487 --- /dev/null +++ b/tests/testthat/test-fzd-calculators-forced.R @@ -0,0 +1,121 @@ +# fzd(model = , calculators = N) must always be forced to +# calculators = 1 before being forwarded to the fz (Python) module, since R +# functions bridged in via reticulate are only safe to call from the main +# thread. On the fz side, calculators > 1 now genuinely dispatches evaluations +# to a worker-thread pool (Funz/fz#73) for ordinary Python functions, so this +# R-side safeguard is what prevents R-function models from crashing the R +# session when a caller passes calculators > 1. + +make_capturing_fz_module <- function(captured) { + list( + fzd = function(input_path, input_variables, model, output_expression, algorithm, + calculators, algorithm_options, analysis_dir) { + captured$calculators <- calculators + list(summary = "ok") + } + ) +} + +a_dummy_r_function <- function(x, y) list(z = x + y) + +test_that("fzd forces calculators to 1 (with a warning) for an R function model when > 1 requested", { + captured <- new.env() + testthat::local_mocked_bindings(get_fz = function() make_capturing_fz_module(captured)) + + expect_warning( + result <- fzd( + input_path = NULL, + input_variables = list(x = "[0;1]", y = "[0;1]"), + model = a_dummy_r_function, + output_expression = "z", + algorithm = "algorithms/randomsampling.py", + calculators = 4L + ), + "forced to 1" + ) + + expect_equal(captured$calculators, 1L) + expect_equal(result$summary, "ok") +}) + +test_that("fzd does not warn and uses calculators = 1 when omitted for an R function model", { + captured <- new.env() + testthat::local_mocked_bindings(get_fz = function() make_capturing_fz_module(captured)) + + expect_no_warning( + fzd( + input_path = NULL, + input_variables = list(x = "[0;1]", y = "[0;1]"), + model = a_dummy_r_function, + output_expression = "z", + algorithm = "algorithms/randomsampling.py" + ) + ) + + expect_equal(captured$calculators, 1L) +}) + +test_that("fzd does not warn when calculators = 1 is explicitly passed for an R function model", { + captured <- new.env() + testthat::local_mocked_bindings(get_fz = function() make_capturing_fz_module(captured)) + + expect_no_warning( + fzd( + input_path = NULL, + input_variables = list(x = "[0;1]", y = "[0;1]"), + model = a_dummy_r_function, + output_expression = "z", + algorithm = "algorithms/randomsampling.py", + calculators = 1L + ) + ) + + expect_equal(captured$calculators, 1L) +}) + +test_that("fzd rejects a non-scalar/non-numeric calculators for an R function model", { + captured <- new.env() + testthat::local_mocked_bindings(get_fz = function() make_capturing_fz_module(captured)) + + expect_error( + fzd( + input_path = NULL, + input_variables = list(x = "[0;1]"), + model = function(x) list(y = x), + output_expression = "y", + algorithm = "algorithms/randomsampling.py", + calculators = c(1L, 2L) + ), + "single integer" + ) + + expect_error( + fzd( + input_path = NULL, + input_variables = list(x = "[0;1]"), + model = function(x) list(y = x), + output_expression = "y", + algorithm = "algorithms/randomsampling.py", + calculators = "sh://" + ), + "single integer" + ) +}) + +test_that("fzd forwards calculators unchanged for a file-based (non-function) model", { + captured <- new.env() + testthat::local_mocked_bindings(get_fz = function() make_capturing_fz_module(captured)) + + expect_no_warning( + fzd( + input_path = "input.txt", + input_variables = list(x = "[0;1]"), + model = list(output = list(y = "cat output.txt")), + output_expression = "y", + algorithm = "algorithms/randomsampling.py", + calculators = 4L + ) + ) + + expect_equal(captured$calculators, 4L) +})