Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 26 additions & 23 deletions R/core-functions.R
Original file line number Diff line number Diff line change
Expand Up @@ -200,39 +200,38 @@ 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"}.
#'
#' @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.
Expand Down Expand Up @@ -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)
#' )
#' }
Expand All @@ -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()
Expand Down
4 changes: 2 additions & 2 deletions R/install.R
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion R/zzz.R
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
37 changes: 18 additions & 19 deletions man/fzd.Rd

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion man/install_algorithm.Rd

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion man/install_model.Rd

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

121 changes: 121 additions & 0 deletions tests/testthat/test-fzd-calculators-forced.R
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
# fzd(model = <R function>, 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)
})
Loading