tests/testthat/test-runShiftRecoverySimulationStudy.R

skip_if_recovery_study_deps <- function() {
  testthat::skip_if_not_installed("ape")
  testthat::skip_if_not_installed("phytools")
  testthat::skip_if_not_installed("mvMORPH")
  testthat::skip_if_not_installed("RRphylo")
  testthat::skip_if_not_installed("future")
  testthat::skip_if_not_installed("future.apply")
  testthat::skip_if_not_installed("progressr")
  withr::local_options(list(progressr.enable = FALSE))
}

.recovery_template_cache <- new.env(parent = emptyenv())

get_cached_recovery_template <- function(key, builder) {
  if (!exists(key, envir = .recovery_template_cache, inherits = FALSE)) {
    assign(key, builder(), envir = .recovery_template_cache)
  }
  unserialize(serialize(get(key, envir = .recovery_template_cache, inherits = FALSE), NULL))
}

make_recovery_template <- function() {
  get_cached_recovery_template("recovery_template", function() {
    set.seed(40)
    tr <- ape::rtree(24)
    X <- matrix(rnorm(24 * 2), ncol = 2)
    rownames(X) <- tr$tip.label
    createSimulationTemplate(
      baseline_tree = tr,
      trait_data = X,
      formula = "trait_data ~ 1",
      method = "LL"
    )
  })
}

make_formula_recovery_template <- function() {
  get_cached_recovery_template("formula_recovery_template", function() {
    set.seed(41)
    tr <- ape::rtree(24)
    grp <- factor(rep(c("a", "b"), each = 12))
    size <- rnorm(24)
    X <- data.frame(
      y1 = 0.5 * size + ifelse(grp == "b", 1, 0) + rnorm(24),
      y2 = -0.3 * size + ifelse(grp == "b", -0.5, 0) + rnorm(24),
      size = size,
      grp = grp,
      row.names = tr$tip.label
    )
    createSimulationTemplate(
      baseline_tree = tr,
      trait_data = X,
      formula = cbind(y1, y2) ~ size + grp,
      method = "LL"
    )
  })
}

make_recovery_template_with_error <- function() {
  get_cached_recovery_template("recovery_template_with_error", function() {
    set.seed(42)
    tr <- ape::rtree(20)
    X <- matrix(rnorm(20 * 2), ncol = 2)
    rownames(X) <- tr$tip.label
    createSimulationTemplate(
      baseline_tree = tr,
      trait_data = X,
      formula = "trait_data ~ 1",
      method = "LL",
      error = TRUE
    )
  })
}

test_that("runShiftRecoverySimulationStudy supports the proportional scenario", {
  testthat::skip_on_cran()
  skip_if_recovery_study_deps()

  tmpl <- make_recovery_template()
  study <- suppressWarnings(
    runShiftRecoverySimulationStudy(
      tmpl,
      n_replicates = 1,
      tree_tip_count = 20,
      simulation_options = list(
        num_shifts = 2,
        min_shift_tips = 3,
        max_shift_tips = 7,
        scale_mode = "proportional"
      ),
      search_options = list(
        formula = "trait_data ~ 1",
        min_descendant_tips = 3,
        shift_acceptance_threshold = 5,
        num_cores = 1,
        IC = "GIC",
        method = "LL",
        uncertaintyweights_par = FALSE
      ),
      weighted = FALSE,
      num_cores = 1,
      seed = 7
    )
  )

  testthat::expect_s3_class(study, "bifrost_simulation_study")
  testthat::expect_identical(study$study_type, "shift_recovery")
  testthat::expect_identical(study$generating_scenario, "proportional")
  testthat::expect_identical(study$simulation_generator, "original")
  testthat::expect_identical(
    unique(study$per_replicate$simulation_generator),
    "original"
  )
  testthat::expect_false("simulation_design" %in% names(study))
  testthat::expect_false("simulation_design" %in% names(study$per_replicate))
  testthat::expect_false(study$search_options$progress)
  testthat::expect_length(study$simdata, 1)
  testthat::expect_true(all(vapply(study$simdata, function(x) length(x$shiftNodes), integer(1)) == 2L))
  testthat::expect_false(any(vapply(study$simdata, function(sim) {
    any(vapply(seq_along(sim$shiftNodes), function(i) {
      any(vapply(seq_along(sim$shiftNodes), function(j) {
        if (i == j) {
          return(FALSE)
        }
        sim$shiftNodes[j] %in% getDescendants(sim$paintedTree, sim$shiftNodes[i])
      }, logical(1)))
    }, logical(1)))
  }, logical(1))))
  testthat::expect_true(all(c("strict", "fuzzy", "weighted", "counts") %in% names(study$evaluation)))
  testthat::expect_identical(as.data.frame(study), study$per_replicate)
})

test_that("runShiftRecoverySimulationStudy supports the correlation scenario", {
  testthat::skip_on_cran()
  skip_if_recovery_study_deps()

  tmpl <- make_recovery_template()
  study <- suppressWarnings(
    runShiftRecoverySimulationStudy(
      tmpl,
      n_replicates = 1,
      tree_tip_count = 20,
      simulation_options = list(
        num_shifts = 2,
        min_shift_tips = 3,
        max_shift_tips = 7,
        scale_mode = "correlation",
        simulation_generator = "empirical"
      ),
      search_options = list(
        formula = "trait_data ~ 1",
        min_descendant_tips = 3,
        shift_acceptance_threshold = 5,
        num_cores = 1,
        IC = "GIC",
        method = "LL",
        uncertaintyweights_par = FALSE
      ),
      weighted = FALSE,
      num_cores = 1,
      seed = 8
    )
  )

  testthat::expect_identical(study$generating_scenario, "correlation")
  testthat::expect_equal(study$per_replicate$generating_scenario, "correlation")
  testthat::expect_identical(study$simulation_generator, "empirical")
  testthat::expect_identical(
    study$per_replicate$simulation_generator,
    "empirical"
  )
})

test_that("runShiftRecoverySimulationStudy stores compact replicate calls", {
  testthat::skip_on_cran()
  skip_if_recovery_study_deps()

  study <- runShiftRecoverySimulationStudy(
    make_recovery_template(),
    n_replicates = 1,
    tree_tip_count = 20,
    simulation_options = list(
      num_shifts = 1,
      min_shift_tips = 3,
      max_shift_tips = 7,
      scale_mode = "proportional"
    ),
    search_options = list(
      formula = "trait_data ~ 1",
      min_descendant_tips = 3,
      shift_acceptance_threshold = 5,
      num_cores = 1,
      IC = "BAD",
      method = "LL",
      uncertaintyweights_par = FALSE
    ),
    weighted = FALSE,
    num_cores = 1,
    seed = 56
  )

  replicate_call <- study$simdata[[1L]]$user_input
  testthat::expect_identical(replicate_call[[1L]], quote(simulateShiftedDataset))
  testthat::expect_identical(names(replicate_call), c("", "num_shifts", "min_shift_tips", "max_shift_tips", "scale_mode", "template", "tree_tip_count"))
  testthat::expect_identical(
    replicate_call$template,
    "<bifrost_simulation_template>"
  )
  testthat::expect_false(is.symbol(replicate_call$template))
  testthat::expect_lt(as.numeric(object.size(replicate_call)), 10000)
})

test_that("the recovery study rejects generator vectors before RNG setup", {
  skip_if_recovery_study_deps()
  tmpl <- make_recovery_template()
  rng_setup_called <- FALSE
  local_rebind(
    ".simulation_set_seed",
    function(...) {
      rng_setup_called <<- TRUE
      stats::runif(1)
      stop("RNG setup reached", call. = FALSE)
    },
    environment(runShiftRecoverySimulationStudy)
  )
  set.seed(20260709)
  before <- .Random.seed
  testthat::expect_error(
    runShiftRecoverySimulationStudy(
      tmpl,
      n_replicates = 1,
      simulation_options = list(
        num_shifts = 1,
        min_shift_tips = 2,
        max_shift_tips = 5,
        simulation_generator = c("original", "empirical")
      ),
      seed = 43
    ),
    "simulation_generator.*original.*empirical"
  )
  testthat::expect_false(rng_setup_called)
  testthat::expect_identical(.Random.seed, before)
})

test_that("runShiftRecoverySimulationStudy rejects per-replicate seeds", {
  testthat::skip_on_cran()
  skip_if_recovery_study_deps()

  tmpl <- make_recovery_template()

  testthat::expect_error(
    runShiftRecoverySimulationStudy(
      tmpl,
      n_replicates = 1,
      simulation_options = list(
        num_shifts = 2,
        min_shift_tips = 3,
        max_shift_tips = 7,
        scale_mode = "proportional",
        seed = 99
      ),
      search_options = list(
        formula = "trait_data ~ 1",
        min_descendant_tips = 3,
        shift_acceptance_threshold = 5,
        num_cores = 1,
        IC = "GIC",
        method = "LL",
        uncertaintyweights_par = FALSE
      ),
      weighted = FALSE,
      num_cores = 1,
      seed = 8
    ),
    "simulation_options\\$seed"
  )
})

test_that("runShiftRecoverySimulationStudy uses intercept-only searches for formula-calibrated templates", {
  testthat::skip_on_cran()
  skip_if_recovery_study_deps()

  tmpl <- make_formula_recovery_template()
  study <- suppressWarnings(
    runShiftRecoverySimulationStudy(
      tmpl,
      n_replicates = 1,
      tree_tip_count = 20,
      simulation_options = list(
        num_shifts = 1,
        min_shift_tips = 3,
        max_shift_tips = 7,
        scale_mode = "proportional"
      ),
      search_options = list(
        formula = "trait_data ~ 1",
        min_descendant_tips = 3,
        shift_acceptance_threshold = 5,
        num_cores = 1,
        IC = "GIC",
        method = "LL",
        uncertaintyweights_par = FALSE
      ),
      weighted = FALSE,
      num_cores = 1,
      seed = 123
    )
  )

  testthat::expect_identical(
    study$search_options$formula,
    simulationStudyFormulaCases$string
  )
  testthat::expect_identical(study$per_replicate$status, "ok")
  testthat::expect_equal(study$per_replicate$n_true_shifts, 1L)
  testthat::expect_equal(colnames(study$simdata[[1]]$trait_data), c("y1", "y2"))
  testthat::expect_false("mass" %in% colnames(study$simdata[[1]]$trait_data))
})

test_that("runShiftRecoverySimulationStudy works in parallel for formula-calibrated templates", {
  testthat::skip_on_cran()
  skip_if_recovery_study_deps()
  testthat::skip_on_covr()

  tmpl <- make_formula_recovery_template()
  study <- suppressWarnings(
    runShiftRecoverySimulationStudy(
      tmpl,
      n_replicates = 2,
      tree_tip_count = 20,
      simulation_options = list(
        num_shifts = 1,
        min_shift_tips = 3,
        max_shift_tips = 7,
        scale_mode = "proportional"
      ),
      search_options = list(
        formula = "trait_data ~ 1",
        min_descendant_tips = 3,
        shift_acceptance_threshold = 5,
        num_cores = 1,
        IC = "GIC",
        method = "LL",
        uncertaintyweights_par = FALSE
      ),
      weighted = FALSE,
      num_cores = 2,
      seed = 124
    )
  )

  testthat::expect_true(all(study$per_replicate$status == "ok"))
  testthat::expect_equal(colnames(study$simdata[[1]]$trait_data), c("y1", "y2"))
  testthat::expect_true(all(is.na(study$per_replicate$error)))
})

test_that("runShiftRecoverySimulationStudy rejects non-intercept search formulas", {
  testthat::skip_on_cran()
  skip_if_recovery_study_deps()

  tmpl <- make_formula_recovery_template()

  testthat::expect_error(
    runShiftRecoverySimulationStudy(
      tmpl,
      n_replicates = 1,
      simulation_options = list(
        num_shifts = 2,
        min_shift_tips = 3,
        max_shift_tips = 7,
        scale_mode = "correlation"
      ),
      search_options = list(
        formula = "trait_data[, 1:2] ~ trait_data[, 3]",
        min_descendant_tips = 3,
        shift_acceptance_threshold = 5,
        num_cores = 1,
        IC = "GIC",
        method = "LL",
        uncertaintyweights_par = FALSE
      ),
      weighted = FALSE,
      num_cores = 1,
      seed = 8
    ),
    simulationStudyFormulaErrors$non_intercept
  )
})

test_that("runShiftRecoverySimulationStudy validates inputs and inherits template error settings", {
  testthat::skip_on_cran()
  skip_if_recovery_study_deps()

  tmpl <- make_recovery_template_with_error()

  testthat::expect_error(
    runShiftRecoverySimulationStudy(list(), n_replicates = 1, simulation_options = list(num_shifts = 1, min_shift_tips = 2, max_shift_tips = 3)),
    "bifrost_simulation_template"
  )
  testthat::expect_error(
    runShiftRecoverySimulationStudy(tmpl, n_replicates = 0, simulation_options = list(num_shifts = 1, min_shift_tips = 2, max_shift_tips = 3)),
    "n_replicates"
  )
  testthat::expect_error(
    runShiftRecoverySimulationStudy(tmpl, n_replicates = 1.5, simulation_options = list(num_shifts = 1, min_shift_tips = 2, max_shift_tips = 3)),
    "n_replicates"
  )
  testthat::expect_error(
    runShiftRecoverySimulationStudy(tmpl, n_replicates = 1, tree_tip_count = 3.5, simulation_options = list(num_shifts = 1, min_shift_tips = 2, max_shift_tips = 3)),
    "tree_tip_count"
  )
  testthat::expect_error(
    runShiftRecoverySimulationStudy(tmpl, n_replicates = 1, tree_tip_count = tmpl$n_tips + 1L, simulation_options = list(num_shifts = 1, min_shift_tips = 2, max_shift_tips = 3)),
    "tree_tip_count cannot exceed"
  )
  testthat::expect_error(
    runShiftRecoverySimulationStudy(tmpl, n_replicates = 1, simulation_options = list(num_shifts = 1, min_shift_tips = 2, max_shift_tips = 3), fuzzy_distance = -1),
    "fuzzy_distance"
  )
  testthat::expect_error(
    runShiftRecoverySimulationStudy(tmpl, n_replicates = 1, simulation_options = list(num_shifts = 1, min_shift_tips = 2, max_shift_tips = 3), fuzzy_distance = 1.5),
    "fuzzy_distance"
  )
  testthat::expect_error(
    runShiftRecoverySimulationStudy(tmpl, n_replicates = 1, simulation_options = list(num_shifts = 1, min_shift_tips = 2, max_shift_tips = 3), weighted = NA),
    "weighted"
  )
  testthat::expect_error(
    runShiftRecoverySimulationStudy(tmpl, n_replicates = 1, simulation_options = list(num_shifts = 1, min_shift_tips = 2, max_shift_tips = 3), num_cores = 0),
    "num_cores"
  )
  testthat::expect_error(
    runShiftRecoverySimulationStudy(tmpl, n_replicates = 1, simulation_options = list(num_shifts = 1, min_shift_tips = 2, max_shift_tips = 3), num_cores = 1.5),
    "num_cores"
  )
  testthat::expect_error(
    runShiftRecoverySimulationStudy(tmpl, n_replicates = 1, simulation_options = list(num_shifts = 1, min_shift_tips = 2, max_shift_tips = 3), seed = 1.5),
    "seed"
  )
  testthat::expect_error(
    runShiftRecoverySimulationStudy(tmpl, n_replicates = 1, simulation_options = 1),
    "must both be lists"
  )
  testthat::expect_error(
    runShiftRecoverySimulationStudy(tmpl, n_replicates = 1, simulation_options = list()),
    "must include num_shifts, min_shift_tips, and max_shift_tips"
  )

  study <- runShiftRecoverySimulationStudy(
    tmpl,
    n_replicates = 1,
    simulation_options = list(
      num_shifts = 1,
      min_shift_tips = 2,
      max_shift_tips = 5,
      scale_mode = "proportional"
    ),
    search_options = list(
      formula = "trait_data ~ 1",
      min_descendant_tips = 2,
      shift_acceptance_threshold = 5,
      num_cores = 1,
      IC = "GIC",
      method = "LL"
    ),
    weighted = FALSE,
    num_cores = 1,
    seed = 43
  )

  testthat::expect_true(isTRUE(study$search_options$error))
})

test_that("runShiftRecoverySimulationStudy restores the caller RNG state when seeded", {
  testthat::skip_on_cran()
  skip_if_recovery_study_deps()

  tmpl <- make_recovery_template()
  local_rebind(
    "simulateShiftedDataset",
    function(template, tree_tip_count = NULL, simulation_options = NULL, ...) {
      stats::rnorm(1)
      list(
        paintedTree = template$baseline_tree,
        trait_data = template$trait_data,
        simulatedData = template$trait_data,
        shiftNodes = ape::Ntip(template$baseline_tree) + 1L,
        generating_scenario = "proportional"
      )
    },
    environment(runShiftRecoverySimulationStudy)
  )
  local_rebind(
    "searchOptimalConfiguration",
    function(...) {
      stats::rnorm(1)
      list(
        shift_nodes_no_uncertainty = integer(0),
        num_candidates = 3L,
        ic_weights = data.frame()
      )
    },
    environment(runShiftRecoverySimulationStudy)
  )

  expect_global_rng_restored(
    runShiftRecoverySimulationStudy(
      tmpl,
      n_replicates = 2,
      tree_tip_count = 20,
      simulation_options = list(
        num_shifts = 1,
        min_shift_tips = 2,
        max_shift_tips = 5,
        scale_mode = "proportional"
      ),
      search_options = list(
        formula = "trait_data ~ 1",
        min_descendant_tips = 2,
        shift_acceptance_threshold = 5,
        num_cores = 1,
        IC = "GIC",
        method = "LL"
      ),
      weighted = FALSE,
      num_cores = 1,
      seed = 43
    )
  )
})

test_that("runShiftRecoverySimulationStudy inherits evaluated template settings", {
  testthat::skip_on_cran()
  skip_if_recovery_study_deps()

  set.seed(47)
  tr <- ape::rtree(18)
  X <- matrix(rnorm(18 * 2), ncol = 2)
  rownames(X) <- tr$tip.label
  method_value <- "LL"
  error_value <- TRUE

  tmpl <- createSimulationTemplate(
    baseline_tree = tr,
    trait_data = X,
    formula = "trait_data ~ 1",
    method = method_value,
    error = error_value
  )

  study <- runShiftRecoverySimulationStudy(
    tmpl,
    n_replicates = 1,
    simulation_options = list(
      num_shifts = 1,
      min_shift_tips = 2,
      max_shift_tips = 5,
      scale_mode = "proportional"
    ),
    search_options = list(
      formula = "trait_data ~ 1",
      min_descendant_tips = 2,
      shift_acceptance_threshold = 5,
      num_cores = 1,
      IC = "GIC"
    ),
    weighted = FALSE,
    num_cores = 1,
    seed = 48
  )

  testthat::expect_identical(study$search_options$method, "LL")
  testthat::expect_true(isTRUE(study$search_options$error))
  testthat::expect_identical(study$per_replicate$status, "ok")
})

test_that("runShiftRecoverySimulationStudy falls back to template call settings when stored values are missing", {
  testthat::skip_on_cran()
  skip_if_recovery_study_deps()

  tmpl <- make_recovery_template_with_error()
  tmpl$fit_method <- NULL
  tmpl$fit_error <- NULL
  tmpl$global_model$call$method <- "LL"
  tmpl$global_model$call$error <- quote(TRUE)
  tmpl$search_formula <- NULL

  study <- runShiftRecoverySimulationStudy(
    tmpl,
    n_replicates = 1,
    simulation_options = list(
      num_shifts = 1,
      min_shift_tips = 2,
      max_shift_tips = 5,
      scale_mode = "proportional"
    ),
    search_options = list(
      min_descendant_tips = 2,
      shift_acceptance_threshold = 5,
      num_cores = 1,
      IC = "GIC"
    ),
    weighted = FALSE,
    num_cores = 1,
    seed = 49
  )

  testthat::expect_identical(study$search_options$formula, "trait_data ~ 1")
  testthat::expect_identical(study$search_options$method, "LL")
  testthat::expect_true(isTRUE(study$search_options$error))
})

test_that("runShiftRecoverySimulationStudy tolerates unevaluable template error calls", {
  testthat::skip_on_cran()
  skip_if_recovery_study_deps()

  tmpl <- make_recovery_template()
  tmpl$fit_method <- NULL
  tmpl$fit_error <- NULL
  tmpl$global_model$call$method <- "LL"
  tmpl$global_model$call$error <- quote(missing_error_flag)
  tmpl$search_formula <- NULL

  study <- runShiftRecoverySimulationStudy(
    tmpl,
    n_replicates = 1,
    simulation_options = list(
      num_shifts = 1,
      min_shift_tips = 2,
      max_shift_tips = 5,
      scale_mode = "proportional"
    ),
    search_options = list(
      min_descendant_tips = 2,
      shift_acceptance_threshold = 5,
      num_cores = 1,
      IC = "GIC"
    ),
    weighted = FALSE,
    num_cores = 1,
    seed = 50
  )

  testthat::expect_false("error" %in% names(study$search_options))
  testthat::expect_identical(study$per_replicate$status, "ok")
})

test_that("runShiftRecoverySimulationStudy is reproducible with the same seed and workers", {
  testthat::skip_on_cran()
  skip_if_recovery_study_deps()
  testthat::skip_on_covr()

  tmpl <- make_recovery_template()
  run_once <- function() {
    suppressWarnings(
      runShiftRecoverySimulationStudy(
        tmpl,
        n_replicates = 2,
        tree_tip_count = 20,
        simulation_options = list(
          num_shifts = 2,
          min_shift_tips = 3,
          max_shift_tips = 7,
          scale_mode = "proportional"
        ),
        search_options = list(
          formula = "trait_data ~ 1",
          min_descendant_tips = 3,
          shift_acceptance_threshold = 5,
          num_cores = 1,
          IC = "GIC",
          method = "LL",
          uncertaintyweights_par = FALSE
        ),
        weighted = FALSE,
        num_cores = 2,
        seed = 7
      )
    )
  }

  study_a <- run_once()
  study_b <- run_once()

  testthat::expect_identical(study_a$per_replicate, study_b$per_replicate)
  testthat::expect_true(all(vapply(seq_along(study_a$simdata), function(i) {
    isTRUE(all.equal(study_a$simdata[[i]]$simulatedData, study_b$simdata[[i]]$simulatedData))
  }, logical(1))))
  testthat::expect_identical(
    lapply(study_a$simdata, `[[`, "shiftNodes"),
    lapply(study_b$simdata, `[[`, "shiftNodes")
  )
  testthat::expect_identical(
    lapply(study_a$results, `[[`, "shift_nodes_no_uncertainty"),
    lapply(study_b$results, `[[`, "shift_nodes_no_uncertainty")
  )
})

test_that("runShiftRecoverySimulationStudy gives seeded draws independent of worker count", {
  testthat::skip_on_cran()
  skip_if_recovery_study_deps()
  testthat::skip_on_covr()

  tmpl <- make_recovery_template()
  local_rebind(
    "simulateShiftedDataset",
    function(template, tree_tip_count = NULL, ...) {
      list(
        paintedTree = template$baseline_tree,
        trait_data = template$trait_data,
        simulatedData = template$trait_data,
        shiftNodes = ape::Ntip(template$baseline_tree) + 1L,
        generating_scenario = "proportional",
        random_draw = stats::rnorm(1)
      )
    },
    environment(runShiftRecoverySimulationStudy)
  )
  local_rebind(
    "searchOptimalConfiguration",
    function(...) {
      list(
        shift_nodes_no_uncertainty = integer(0),
        num_candidates = 3L,
        ic_weights = data.frame(),
        random_draw = stats::rnorm(1)
      )
    },
    environment(runShiftRecoverySimulationStudy)
  )
  withr::local_envvar(c(
    RSTUDIO = "1",
    RSTUDIO_SESSION_INITIALIZED = "1"
  ))

  run_once <- function(num_cores) {
    runShiftRecoverySimulationStudy(
      tmpl,
      n_replicates = 2,
      tree_tip_count = 20,
      simulation_options = list(
        num_shifts = 1,
        min_shift_tips = 2,
        max_shift_tips = 5,
        scale_mode = "proportional"
      ),
      search_options = list(
        formula = "trait_data ~ 1",
        min_descendant_tips = 2,
        shift_acceptance_threshold = 5,
        num_cores = 1,
        IC = "GIC",
        method = "LL"
      ),
      weighted = FALSE,
      num_cores = num_cores,
      seed = 707
    )
  }

  serial <- run_once(1L)
  multisession <- run_once(2L)

  testthat::expect_identical(
    vapply(serial$simdata, `[[`, numeric(1), "random_draw"),
    vapply(multisession$simdata, `[[`, numeric(1), "random_draw")
  )
  testthat::expect_identical(
    vapply(serial$results, `[[`, numeric(1), "random_draw"),
    vapply(multisession$results, `[[`, numeric(1), "random_draw")
  )
})

test_that("runShiftRecoverySimulationStudy makes outer parallelism override inner cores", {
  testthat::skip_on_cran()
  skip_if_recovery_study_deps()

  tmpl <- make_recovery_template()

  testthat::local_mocked_bindings(
    future_lapply = serial_future_lapply,
    .package = "future.apply"
  )
  testthat::local_mocked_bindings(
    plan = function(...) list(),
    .package = "future"
  )
  local_rebind(
    "searchOptimalConfiguration",
    function(...) {
      list(
        shift_nodes_no_uncertainty = integer(0),
        num_candidates = 3L,
        ic_weights = data.frame(
          node = integer(0),
          ic_with_shift = numeric(0),
          ic_without_shift = numeric(0),
          delta_ic = numeric(0),
          ic_weight_withshift = numeric(0),
          ic_weight_withoutshift = numeric(0),
          evidence_ratio = numeric(0)
        )
      )
    },
    environment(runShiftRecoverySimulationStudy)
  )

  testthat::expect_warning(
    study <- runShiftRecoverySimulationStudy(
      tmpl,
      n_replicates = 1,
      simulation_options = list(
        num_shifts = 1,
        min_shift_tips = 2,
        max_shift_tips = 5,
        scale_mode = "proportional"
      ),
      search_options = list(
        formula = "trait_data ~ 1",
        min_descendant_tips = 2,
        shift_acceptance_threshold = 5,
        num_cores = 2,
        IC = "GIC",
        method = "LL"
      ),
      weighted = FALSE,
      num_cores = 2,
      seed = 44
    ),
    "forcing each search to use one core"
  )
  testthat::expect_identical(study$search_options$num_cores, 1L)
})

test_that("runShiftRecoverySimulationStudy does not capture simdata in future workers", {
  testthat::skip_on_cran()
  skip_if_recovery_study_deps()

  worker_environments <- list()
  testthat::local_mocked_bindings(
    future_lapply = function(X, FUN, ...) {
      worker_environments[[length(worker_environments) + 1L]] <<- environment(FUN)
      serial_future_lapply(X, FUN, ...)
    },
    .package = "future.apply"
  )
  testthat::local_mocked_bindings(
    plan = function(...) list(),
    .package = "future"
  )
  local_rebind(
    "searchOptimalConfiguration",
    function(...) {
      list(
        shift_nodes_no_uncertainty = integer(0),
        num_candidates = 3L,
        ic_weights = data.frame()
      )
    },
    environment(runShiftRecoverySimulationStudy)
  )

  runShiftRecoverySimulationStudy(
    make_recovery_template(),
    n_replicates = 1,
    tree_tip_count = 20,
    simulation_options = list(
      num_shifts = 1,
      min_shift_tips = 3,
      max_shift_tips = 7,
      scale_mode = "proportional"
    ),
    search_options = list(
      formula = "trait_data ~ 1",
      min_descendant_tips = 3,
      shift_acceptance_threshold = 5,
      num_cores = 1,
      IC = "GIC",
      method = "LL",
      uncertaintyweights_par = FALSE
    ),
    weighted = FALSE,
    num_cores = 2,
    seed = 55
  )

  testthat::expect_length(worker_environments, 2L)
  testthat::expect_false(any(vapply(worker_environments, function(worker_env) {
    exists("simdata", envir = worker_env, inherits = FALSE)
  }, logical(1L))))
})

test_that("runShiftRecoverySimulationStudy validates formula types and can choose multisession plans", {
  testthat::skip_on_cran()
  skip_if_recovery_study_deps()

  tmpl <- make_recovery_template()

  testthat::expect_error(
    runShiftRecoverySimulationStudy(
      tmpl,
      n_replicates = 1,
      simulation_options = list(
        num_shifts = 1,
        min_shift_tips = 2,
        max_shift_tips = 5,
        scale_mode = "proportional"
      ),
      search_options = list(
        formula = 1,
        min_descendant_tips = 2,
        shift_acceptance_threshold = 5,
        num_cores = 1,
        IC = "GIC"
      ),
      weighted = FALSE,
      num_cores = 1,
      seed = 51
    ),
    simulationStudyFormulaErrors$invalid_type
  )

  plans <- list()
  old_rstudio <- Sys.getenv("RSTUDIO", unset = NA_character_)
  old_rstudio_init <- Sys.getenv("RSTUDIO_SESSION_INITIALIZED", unset = NA_character_)
  Sys.setenv(RSTUDIO = "1", RSTUDIO_SESSION_INITIALIZED = "1")
  on.exit({
    if (is.na(old_rstudio)) Sys.unsetenv("RSTUDIO") else Sys.setenv(RSTUDIO = old_rstudio)
    if (is.na(old_rstudio_init)) Sys.unsetenv("RSTUDIO_SESSION_INITIALIZED") else Sys.setenv(RSTUDIO_SESSION_INITIALIZED = old_rstudio_init)
  }, add = TRUE)

  testthat::local_mocked_bindings(
    future_lapply = serial_future_lapply,
    .package = "future.apply"
  )
  testthat::local_mocked_bindings(
    plan = function(strategy, workers = NULL, ...) {
      if (missing(strategy)) {
        return("old_plan")
      }
      plans[[length(plans) + 1L]] <<- list(strategy = strategy, workers = workers)
      invisible("mock_plan")
    },
    .package = "future"
  )
  local_rebind(
    "searchOptimalConfiguration",
    function(...) {
      list(
        shift_nodes_no_uncertainty = integer(0),
        num_candidates = 3L,
        ic_weights = data.frame(
          node = integer(0),
          ic_with_shift = numeric(0),
          ic_without_shift = numeric(0),
          delta_ic = numeric(0),
          ic_weight_withshift = numeric(0),
          ic_weight_withoutshift = numeric(0),
          evidence_ratio = numeric(0)
        )
      )
    },
    environment(runShiftRecoverySimulationStudy)
  )

  runShiftRecoverySimulationStudy(
    tmpl,
    n_replicates = 1,
    simulation_options = list(
      num_shifts = 1,
      min_shift_tips = 2,
      max_shift_tips = 5,
      scale_mode = "proportional"
    ),
    search_options = list(
      formula = "trait_data ~ 1",
      min_descendant_tips = 2,
      shift_acceptance_threshold = 5,
      num_cores = 1,
      IC = "GIC",
      method = "LL"
    ),
    weighted = FALSE,
    num_cores = 2,
    seed = 52
  )

  testthat::expect_true(any(vapply(plans, function(x) identical(x$strategy, future::multisession), logical(1))))
})

test_that("runShiftRecoverySimulationStudy accepts formula objects in search options", {
  testthat::skip_on_cran()
  skip_if_recovery_study_deps()

  tmpl <- make_recovery_template()
  study <- runShiftRecoverySimulationStudy(
    tmpl,
    n_replicates = 1,
    simulation_options = list(
      num_shifts = 1,
      min_shift_tips = 2,
      max_shift_tips = 5,
      scale_mode = "proportional"
    ),
    search_options = list(
      formula = simulationStudyFormulaCases$formula,
      min_descendant_tips = 2,
      shift_acceptance_threshold = 5,
      num_cores = 1,
      IC = "GIC",
      method = "LL"
    ),
    weighted = FALSE,
    num_cores = 1,
    seed = 53
  )

  testthat::expect_s3_class(study$search_options$formula, "formula")
  testthat::expect_identical(
    study$search_options$formula,
    simulationStudyFormulaCases$formula
  )
  testthat::expect_identical(study$per_replicate$status, "ok")
})

test_that("runShiftRecoverySimulationStudy records search fallback errors", {
  testthat::skip_on_cran()
  skip_if_recovery_study_deps()

  tmpl <- make_recovery_template()

  study <- runShiftRecoverySimulationStudy(
    tmpl,
    n_replicates = 1,
    simulation_options = list(
      num_shifts = 1,
      min_shift_tips = 2,
      max_shift_tips = 5,
      scale_mode = "proportional"
    ),
    search_options = list(
      formula = "trait_data ~ 1",
      min_descendant_tips = 2,
      shift_acceptance_threshold = 5,
      num_cores = 1,
      IC = "BAD",
      method = "LL"
    ),
    weighted = FALSE,
    num_cores = 1,
    seed = 45
  )

  testthat::expect_identical(study$per_replicate$status, "error")
  testthat::expect_match(study$per_replicate$error, "IC must be GIC or BIC")
  testthat::expect_true(is.na(study$per_replicate$n_inferred_shifts))
  testthat::expect_identical(study$study_summary$n_completed, 0L)
  testthat::expect_identical(study$study_summary$n_failed, 1L)
  testthat::expect_identical(study$study_summary$n_evaluable_replicates, 0L)
  testthat::expect_equal(study$study_summary$completion_rate, 0)
  testthat::expect_equal(study$study_summary$failure_rate, 1)
  testthat::expect_equal(unname(study$evaluation$counts$strict), rep(0, 4))
  testthat::expect_equal(unname(study$evaluation$counts$fuzzy), rep(0, 4))
  testthat::expect_true(all(is.na(unlist(study$evaluation$strict))))
  testthat::expect_true(all(is.na(unlist(study$evaluation$fuzzy))))
  expected_candidates <- generatePaintedTrees(
    ape::as.phylo(study$simdata[[1]]$paintedTree),
    min_tips = 2
  )[-1]
  testthat::expect_equal(
    study$results[[1]]$num_candidates,
    length(expected_candidates)
  )
  testthat::expect_identical(
    study$results[[1]]$candidate_nodes,
    as.integer(sub("^Node ", "", names(expected_candidates)))
  )
})

test_that("runShiftRecoverySimulationStudy marks zero-candidate recovery metrics as unevaluable", {
  testthat::skip_on_cran()
  skip_if_recovery_study_deps()

  tmpl <- make_recovery_template()

  study <- runShiftRecoverySimulationStudy(
    tmpl,
    n_replicates = 1,
    simulation_options = list(
      num_shifts = 1,
      min_shift_tips = 2,
      max_shift_tips = 5,
      scale_mode = "proportional"
    ),
    search_options = list(
      formula = "trait_data ~ 1",
      min_descendant_tips = 100,
      shift_acceptance_threshold = 5,
      num_cores = 1,
      IC = "GIC",
      method = "LL"
    ),
    weighted = FALSE,
    num_cores = 1,
    seed = 49
  )

  testthat::expect_equal(study$per_replicate$n_candidates, 0)
  testthat::expect_true(is.na(study$evaluation$strict$specificity))
  testthat::expect_true(is.na(study$evaluation$strict$fpr))
  testthat::expect_true(is.na(study$evaluation$strict$balanced_accuracy))
  testthat::expect_true(is.na(study$evaluation$fuzzy$specificity))
  testthat::expect_true(is.na(study$evaluation$fuzzy$fpr))
  testthat::expect_true(is.na(study$evaluation$fuzzy$balanced_accuracy))
})

test_that("runShiftRecoverySimulationStudy handles missing trait_data and mixed scenarios", {
  testthat::skip_on_cran()
  skip_if_recovery_study_deps()

  tmpl <- make_recovery_template()
  tr <- phytools::paintSubTree(ape::rtree(6), node = 7, state = "ancestral", anc.state = "ancestral")
  sim_stub <- list(
    list(
      paintedTree = tr,
      shiftNodes = 7L,
      simulatedData = matrix(rnorm(12), ncol = 2, dimnames = list(tr$tip.label, c("y1", "y2"))),
      trait_data = NULL,
      generating_scenario = "proportional"
    ),
    list(
      paintedTree = tr,
      shiftNodes = 7L,
      simulatedData = matrix(rnorm(12), ncol = 2, dimnames = list(tr$tip.label, c("y1", "y2"))),
      trait_data = NULL,
      generating_scenario = "correlation"
    )
  )

  mock_env <- new.env(parent = emptyenv())
  mock_env$sim_i <- 0L
  mock_env$search_i <- 0L
  mock_env$seen_trait_data <- vector("list", length(sim_stub))
  local_rebind(
    "simulateShiftedDataset",
    function(...) {
      mock_env$sim_i <- mock_env$sim_i + 1L
      sim_stub[[mock_env$sim_i]]
    },
    environment(runShiftRecoverySimulationStudy)
  )
  local_rebind(
    "searchOptimalConfiguration",
    function(baseline_tree, trait_data, ...) {
      mock_env$search_i <- mock_env$search_i + 1L
      search_i <- mock_env$search_i
      mock_env$seen_trait_data[[search_i]] <- trait_data
      list(
        shift_nodes_no_uncertainty = integer(0),
        num_candidates = 3L,
        ic_weights = data.frame(
          node = integer(0),
          ic_with_shift = numeric(0),
          ic_without_shift = numeric(0),
          delta_ic = numeric(0),
          ic_weight_withshift = numeric(0),
          ic_weight_withoutshift = numeric(0),
          evidence_ratio = numeric(0)
        )
      )
    },
    environment(runShiftRecoverySimulationStudy)
  )
  local_rebind(
    "evaluateShiftRecovery",
    function(...) {
      list(
        strict = list(precision = 0, recall = 0),
        fuzzy = list(precision = 0, recall = 0),
        weighted = NULL,
        counts = list(strict = c(TP = 0, FP = 0, FN = 0, TN = 0), fuzzy = c(TP = 0, FP = 0, FN = 0, TN = 0))
      )
    },
    environment(runShiftRecoverySimulationStudy)
  )
  study <- runShiftRecoverySimulationStudy(
    tmpl,
    n_replicates = 2,
    simulation_options = list(
      num_shifts = 1,
      min_shift_tips = 2,
      max_shift_tips = 5,
      scale_mode = "proportional"
    ),
    search_options = list(
      formula = "trait_data ~ 1",
      min_descendant_tips = 2,
      shift_acceptance_threshold = 5,
      num_cores = 1,
      IC = "GIC",
      method = "LL"
    ),
    weighted = FALSE,
    num_cores = 1,
    seed = 46
  )

  testthat::expect_identical(study$generating_scenario, c("proportional", "correlation"))
  testthat::expect_equal(mock_env$seen_trait_data, lapply(sim_stub, `[[`, "simulatedData"))
})

test_that("runShiftRecoverySimulationStudy integrates weighted metrics across mixed evaluable replicates", {
  testthat::skip_on_cran()
  skip_if_recovery_study_deps()

  tmpl <- make_recovery_template()
  tr <- phytools::paintSubTree(ape::rtree(6), node = 7, state = "ancestral", anc.state = "ancestral")
  sim_stub <- list(
    list(
      paintedTree = tr,
      shiftNodes = 7L,
      simulatedData = matrix(rnorm(12), ncol = 2, dimnames = list(tr$tip.label, c("y1", "y2"))),
      trait_data = NULL,
      generating_scenario = "proportional"
    ),
    list(
      paintedTree = tr,
      shiftNodes = 7L,
      simulatedData = matrix(rnorm(12), ncol = 2, dimnames = list(tr$tip.label, c("y1", "y2"))),
      trait_data = NULL,
      generating_scenario = "proportional"
    )
  )
  result_stub <- list(
    list(
      shift_nodes_no_uncertainty = NULL,
      num_candidates = 0L,
      ic_weights = data.frame(
        node = integer(0),
        ic_with_shift = numeric(0),
        ic_without_shift = numeric(0),
        delta_ic = numeric(0),
        ic_weight_withshift = numeric(0),
        ic_weight_withoutshift = numeric(0),
        evidence_ratio = numeric(0)
      )
    ),
    list(
      shift_nodes_no_uncertainty = 7L,
      num_candidates = 4L,
      ic_weights = data.frame(
        node = 7L,
        ic_with_shift = 1,
        ic_without_shift = 2,
        delta_ic = 1,
        ic_weight_withshift = 0.8,
        ic_weight_withoutshift = 0.2,
        evidence_ratio = 4
      )
    )
  )

  mock_env <- new.env(parent = emptyenv())
  mock_env$sim_i <- 0L
  mock_env$search_i <- 0L
  mock_env$seen_trait_data <- vector("list", length(sim_stub))
  local_rebind(
    "simulateShiftedDataset",
    function(...) {
      mock_env$sim_i <- mock_env$sim_i + 1L
      sim_stub[[mock_env$sim_i]]
    },
    environment(runShiftRecoverySimulationStudy)
  )
  local_rebind(
    "searchOptimalConfiguration",
    function(baseline_tree, trait_data, ...) {
      mock_env$search_i <- mock_env$search_i + 1L
      search_i <- mock_env$search_i
      mock_env$seen_trait_data[[search_i]] <- trait_data
      result_stub[[search_i]]
    },
    environment(runShiftRecoverySimulationStudy)
  )

  study <- runShiftRecoverySimulationStudy(
    tmpl,
    n_replicates = 2,
    simulation_options = list(
      num_shifts = 1,
      min_shift_tips = 2,
      max_shift_tips = 5,
      scale_mode = "proportional"
    ),
    search_options = list(
      formula = "trait_data ~ 1",
      min_descendant_tips = 2,
      shift_acceptance_threshold = 5,
      num_cores = 1,
      IC = "GIC",
      method = "LL"
    ),
    weighted = TRUE,
    num_cores = 1,
    seed = 50
  )

  testthat::expect_equal(study$per_replicate$n_candidates, c(0, 4))
  testthat::expect_equal(study$per_replicate$n_inferred_shifts, c(0, 1))
  testthat::expect_identical(study$study_summary$n_evaluable_replicates, 2L)
  testthat::expect_equal(mock_env$seen_trait_data, lapply(sim_stub, `[[`, "simulatedData"))
  testthat::expect_false(is.null(study$evaluation$weighted))
  testthat::expect_equal(study$evaluation$strict$precision, 1)
  testthat::expect_equal(study$evaluation$strict$recall, 0.5)
  testthat::expect_equal(study$evaluation$strict$specificity, 1)
  testthat::expect_equal(study$evaluation$weighted$strict$precision, 1)
  testthat::expect_equal(study$evaluation$weighted$strict$recall, 0.4)
})

Try the bifrost package in your browser

Any scripts or data that you put into this service are public.

bifrost documentation built on Nov. 5, 2026, 9:07 a.m.