tests/testthat/test-nonlinear-tests.R

box::use(
  testthat[
    expect_equal,
    expect_error,
    expect_false,
    expect_gt,
    expect_identical,
    expect_length,
    expect_match,
    expect_named,
    expect_s3_class,
    expect_setequal,
    expect_true,
    test_that
  ],
  withr[local_options, local_tempdir]
)

box::use(
  artma / methods / nonlinear_tests[nonlinear_tests],
  artma / econometric / nonlinear[run_nonlinear_methods]
)

make_demo_data <- function() {
  # STEM picks the MSE-minimising number of (precision-ordered) studies; a
  # small, widely spread study set can land on its algorithmic floor of 3,
  # which the panel now treats as a degenerate corner solution and skips.
  # More studies with tightly clustered effects keep STEM's window well
  # above that floor so this fixture continues to exercise every model.
  n_studies <- 15L
  per_study <- 6L
  study_ids <- rep(paste0("S", seq_len(n_studies)), each = per_study)
  base_se <- seq(0.05, 0.12, length.out = per_study)
  se_vals <- rep(base_se, times = n_studies)
  effect_base <- 0.15 + seq(-0.01, 0.01, length.out = n_studies)
  effect_offsets <- seq(-0.02, 0.02, length.out = per_study)
  effect_vals <- rep(effect_base, each = per_study) + rep(effect_offsets, times = n_studies)
  data.frame(
    study_id = study_ids,
    effect = effect_vals,
    se = se_vals,
    stringsAsFactors = FALSE
  )
}

test_that("nonlinear tests return tidy coefficients and summary", {
  df <- make_demo_data()

  local_options(
    "artma.methods.nonlinear_tests.add_significance_marks" = TRUE,
    "artma.methods.nonlinear_tests.round_to" = 2L,
    "artma.methods.nonlinear_tests.stem_representative_sample" = "medians",
    "artma.methods.nonlinear_tests.selection_cutoffs" = c(1.96, 2.58),
    "artma.methods.nonlinear_tests.selection_symmetric" = FALSE,
    "artma.methods.nonlinear_tests.selection_model" = "normal",
    "artma.methods.nonlinear_tests.hierarchical_iterations" = 50L,
    "artma.output.number_of_decimals" = 3L,
    "artma.visualization.export_graphics" = FALSE,
    "artma.verbose" = 0L
  )

  res <- suppressWarnings(nonlinear_tests(df))

  expect_named(res, c("tables", "estimates", "plots", "meta"))
  expect_named(res$tables, "summary")
  expect_named(res$plots, c("stem_funnel", "stem_mse"))
  expect_s3_class(res$plots$stem_funnel, "recordedplot")
  expect_s3_class(res$plots$stem_mse, "recordedplot")
  expect_named(
    res$meta,
    c("coefficients", "skipped_models", "options"),
    ignore.order = TRUE
  )

  expect_gt(nrow(res$meta$coefficients), 0L)
  expect_named(
    res$meta$coefficients,
    c(
      "model", "model_label", "term", "term_label", "estimate", "std_error",
      "p_value", "n_obs_total", "n_obs_model", "estimate_formatted",
      "std_error_formatted"
    )
  )
  # STEM (and, depending on the fixture, Hierarchical and Endogenous Kink)
  # all report their heterogeneity estimate under the same shared term/label
  # as the Selection model's tau, so it folds into a single extra row pair
  # regardless of how many of them succeed on this fixture.
  expect_setequal(unique(res$meta$coefficients$term), c("publication_bias", "effect", "effect_heterogeneity"))
  expect_true(all(res$meta$coefficients$n_obs_total == nrow(df)))

  expect_gt(nrow(res$tables$summary), 0L)
  expect_equal(
    rownames(res$tables$summary),
    c(
      "Publication Bias", "(Std. Error)", "Effect Beyond Bias",
      "(Std. Error)", "Effect Heterogeneity (tau)", "(Std. Error)",
      "Total observations", "Model observations"
    )
  )
  expect_equal(res$tables$summary$Metric, rownames(res$tables$summary))
  expect_equal(res$meta$options$round_to, 2L)
})

test_that("nonlinear tests writes STEM diagnostic plot files when export is enabled", {
  df <- make_demo_data()
  dir <- local_tempdir()

  local_options(
    "artma.methods.nonlinear_tests.stem_representative_sample" = "medians",
    "artma.visualization.export_graphics" = TRUE,
    "artma.visualization.export_path" = dir,
    "artma.output.save_results" = FALSE,
    "artma.verbose" = 0L
  )

  res <- suppressWarnings(nonlinear_tests(df))

  expect_setequal(list.files(dir), c("stem_funnel.png", "stem_mse.png"))
  expect_s3_class(res$plots$stem_funnel, "recordedplot")
  expect_s3_class(res$plots$stem_mse, "recordedplot")
})

# Model-generated data with genuine study-level heterogeneity and effects of
# mixed sign and significance, so every publication-probability interval of
# the standard cutoff specs is populated and the selection model converges to
# an interior solution.
make_mixed_significance_data <- function() {
  set.seed(2024)
  n_studies <- 15L
  per_study <- 6L
  n <- n_studies * per_study
  study_ids <- rep(paste0("S", seq_len(n_studies)), each = per_study)
  study_effect <- rnorm(n_studies, mean = 0.1, sd = 0.3)
  se_vals <- runif(n, 0.05, 0.35)
  effect_vals <- rep(study_effect, each = per_study) + rnorm(n, 0, se_vals)
  data.frame(
    study_id = study_ids,
    effect = effect_vals,
    se = se_vals,
    stringsAsFactors = FALSE
  )
}

test_that("selection model runs with negative and zero cutoffs and restructured labels", {
  df <- make_mixed_significance_data()

  local_options(
    "artma.methods.nonlinear_tests.selection_cutoffs" = c(-1.96, 0, 1.96),
    "artma.methods.nonlinear_tests.selection_symmetric" = FALSE,
    "artma.methods.nonlinear_tests.selection_model" = "normal",
    "artma.methods.nonlinear_tests.hierarchical_iterations" = 20L,
    "artma.visualization.export_graphics" = FALSE,
    "artma.verbose" = 0L
  )

  res <- suppressWarnings(suppressMessages(nonlinear_tests(df)))

  expect_false("selection" %in% names(res$meta$skipped_models))
  selection <- res$meta$coefficients[res$meta$coefficients$model == "selection", , drop = FALSE]
  expect_setequal(
    selection$term,
    c(
      "publication_bias", "effect", "effect_heterogeneity",
      "pub_prob_1", "pub_prob_2", "pub_prob_3"
    )
  )
  expect_setequal(
    selection$term_label[grepl("^pub_prob_", selection$term)],
    c(
      "Rel. Pub. Probability t < -1.96",
      "Rel. Pub. Probability -1.96 <= t < 0",
      "Rel. Pub. Probability 0 <= t < 1.96"
    )
  )

  # tau is reported as heterogeneity, not as publication bias: the selection
  # model carries no publication-bias estimate at all.
  expect_true(is.na(selection$estimate[selection$term == "publication_bias"]))
  tau_row <- selection[selection$term == "effect_heterogeneity", , drop = FALSE]
  expect_identical(tau_row$term_label, "Effect Heterogeneity (tau)")
  expect_true(is.finite(tau_row$estimate) && is.finite(tau_row$std_error))

  summary <- res$tables$summary
  expect_true("Effect Heterogeneity (tau)" %in% summary$Metric)
  expect_true(any(grepl("Rel. Pub. Probability", summary$Metric, fixed = TRUE)))
  expect_identical(summary$Selection[summary$Metric == "Publication Bias"], "NA")
})

test_that("STEM, Hierarchical, and Endogenous Kink report effect heterogeneity (tau)", {
  df <- make_mixed_significance_data()

  local_options(
    "artma.methods.nonlinear_tests.add_significance_marks" = TRUE,
    "artma.methods.nonlinear_tests.selection_cutoffs" = c(-1.96, 0, 1.96),
    "artma.methods.nonlinear_tests.selection_symmetric" = FALSE,
    "artma.methods.nonlinear_tests.selection_model" = "normal",
    "artma.methods.nonlinear_tests.hierarchical_iterations" = 20L,
    "artma.visualization.export_graphics" = FALSE,
    "artma.verbose" = 0L
  )

  res <- suppressWarnings(suppressMessages(nonlinear_tests(df)))
  coefficients <- res$meta$coefficients

  # STEM and Endogenous Kink compute a heterogeneity SD internally but have
  # no standard error for it: a finite estimate paired with a genuinely-NA
  # (not NaN) std_error is the documented "absent by design" signal that
  # degenerate_effect_reason() and the formatters already understand, so the
  # table cell renders with no parenthetical and no significance stars.
  for (model in c("stem", "endogenous_kink")) {
    tau_row <- coefficients[coefficients$model == model & coefficients$term == "effect_heterogeneity", , drop = FALSE]
    expect_identical(nrow(tau_row), 1L)
    expect_identical(tau_row$term_label, "Effect Heterogeneity (tau)")
    expect_true(is.finite(tau_row$estimate))
    expect_true(is.na(tau_row$std_error) && !is.nan(tau_row$std_error))
    expect_true(is.na(tau_row$p_value))
    expect_identical(tau_row$std_error_formatted, "")
    expect_false(grepl("*", tau_row$estimate_formatted, fixed = TRUE))
  }

  # Hierarchical derives both an estimate and a standard error from posterior
  # draws of Vbeta (the cross-study coefficient covariance), so it gets a
  # full estimate/SE pair, just like Selection's own tau.
  hier_row <- coefficients[coefficients$model == "hierarchical" & coefficients$term == "effect_heterogeneity", , drop = FALSE]
  expect_identical(nrow(hier_row), 1L)
  expect_identical(hier_row$term_label, "Effect Heterogeneity (tau)")
  expect_true(is.finite(hier_row$estimate) && is.finite(hier_row$std_error))

  summary <- res$tables$summary
  expect_true("Effect Heterogeneity (tau)" %in% summary$Metric)
})

test_that("selection model runs with a single zero cutoff (sign selection)", {
  df <- make_mixed_significance_data()

  local_options(
    "artma.methods.nonlinear_tests.selection_cutoffs" = c(0),
    "artma.methods.nonlinear_tests.selection_symmetric" = FALSE,
    "artma.methods.nonlinear_tests.selection_model" = "normal",
    "artma.methods.nonlinear_tests.hierarchical_iterations" = 20L,
    "artma.visualization.export_graphics" = FALSE,
    "artma.verbose" = 0L
  )

  res <- suppressWarnings(suppressMessages(nonlinear_tests(df)))

  expect_false("selection" %in% names(res$meta$skipped_models))
  selection <- res$meta$coefficients[res$meta$coefficients$model == "selection", , drop = FALSE]
  expect_true("pub_prob_1" %in% selection$term)
  expect_true("Rel. Pub. Probability t < 0" %in% selection$term_label)
})

test_that("selection cutoffs must be strictly increasing without duplicates", {
  df <- make_mixed_significance_data()

  local_options(
    "artma.methods.nonlinear_tests.selection_cutoffs" = c(1.96, 1.96),
    "artma.verbose" = 0L
  )

  expect_error(suppressWarnings(nonlinear_tests(df)), "strictly increasing")
})

make_degenerate_options <- function() {
  list(
    add_significance_marks = FALSE,
    round_to = 3L,
    stem_representative_sample = "medians",
    selection_cutoffs = c(1.96),
    selection_symmetric = FALSE,
    selection_model = "normal",
    hierarchical_iterations = 10L
  )
}

# The publication-probability cells are normalised against the interval above
# the highest cutoff, which never appears as a row of its own, and their
# significance marks test a difference from 1 rather than from 0 like every
# other row. Neither fact is recoverable from the table, so a note carries them.
test_that("the results carry a note naming the publication-probability reference interval", {
  df <- make_mixed_significance_data()
  options <- list(
    add_significance_marks = TRUE,
    round_to = 3L,
    stem_representative_sample = "medians",
    selection_cutoffs = c(1.96, 2.58),
    selection_symmetric = TRUE,
    selection_model = "normal",
    hierarchical_iterations = 50L
  )

  res <- suppressWarnings(suppressMessages(run_nonlinear_methods(df, options)))

  expect_true(any(startsWith(res$coefficients$term, "pub_prob_")))
  expect_length(res$notes, 1L)
  expect_match(res$notes, "relative to |t| >= 2.58", fixed = TRUE)
  expect_match(res$notes, "difference from 1, not from 0", fixed = TRUE)
})

test_that("the publication-probability note appears exactly when those rows do", {
  res <- suppressWarnings(suppressMessages(
    run_nonlinear_methods(make_demo_data(), make_degenerate_options())
  ))

  expect_identical(
    length(res$notes) > 0L,
    any(startsWith(res$coefficients$term, "pub_prob_"))
  )
})

# Characterization test for B7 (formatting-helper consolidation): pins the
# exact formatted cells produced by the deterministic estimators (WAAP, STEM),
# including the current literal "NA" string rendered for a model that has no
# publication-bias term. This must stay byte-for-byte identical across the
# format_estimate()/format_standard_error()/normal_p_value() consolidation.
test_that("WAAP and STEM formatted cells are pinned across the formatting refactor", {
  df <- make_demo_data()
  options <- list(
    add_significance_marks = TRUE,
    round_to = 2L,
    stem_representative_sample = "medians",
    selection_cutoffs = c(1.96, 2.58),
    selection_symmetric = FALSE,
    selection_model = "normal",
    hierarchical_iterations = 50L
  )

  res <- suppressWarnings(run_nonlinear_methods(df, options))

  waap_rows <- res$coefficients[res$coefficients$model == "waap", ]
  waap_pb <- waap_rows[waap_rows$term == "publication_bias", ]
  waap_eff <- waap_rows[waap_rows$term == "effect", ]
  expect_identical(waap_pb$estimate_formatted, "NA")
  expect_identical(waap_pb$std_error_formatted, "")
  expect_identical(waap_eff$estimate_formatted, "0.13***")
  # The WAAP standard error is the study-clustered WLS regression SE; the
  # fixture effects are nearly homogeneous, so it rounds below 0.01 here.
  expect_identical(waap_eff$std_error_formatted, "(0.00)")

  stem_rows <- res$coefficients[res$coefficients$model == "stem", ]
  stem_pb <- stem_rows[stem_rows$term == "publication_bias", ]
  stem_eff <- stem_rows[stem_rows$term == "effect", ]
  expect_identical(stem_pb$estimate_formatted, "NA")
  expect_identical(stem_pb$std_error_formatted, "")
  expect_identical(stem_eff$estimate_formatted, "0.15***")
  expect_identical(stem_eff$std_error_formatted, "(0.02)")

  summary <- res$summary
  expect_identical(summary$WAAP[summary$Metric == "Publication Bias"], "NA")
  expect_identical(summary$Stem[summary$Metric == "Publication Bias"], "NA")
})

test_that("WAAP and Top10 are skipped when a single observation dominates the weights", {
  n_moderate <- 20
  df <- data.frame(
    effect = rep(0.2, n_moderate + 1),
    se = c(rep(0.05, n_moderate), 0.0001),
    study_id = paste0("S", seq_len(n_moderate + 1)),
    stringsAsFactors = FALSE
  )

  res <- suppressWarnings(run_nonlinear_methods(df, make_degenerate_options()))

  expect_true("waap" %in% names(res$skipped))
  expect_match(res$skipped$waap$reason, "dominated by a single extreme-precision observation")
  expect_false("waap" %in% res$coefficients$model)
})

test_that("Top10 is skipped when a single observation dominates the weights", {
  df <- data.frame(
    effect = rep(0.2, 50),
    se = c(rep(0.05, 45), rep(0.01, 4), 0.0001),
    study_id = paste0("S", seq_len(50)),
    stringsAsFactors = FALSE
  )

  res <- suppressWarnings(run_nonlinear_methods(df, make_degenerate_options()))

  expect_true("top10" %in% names(res$skipped))
  expect_match(res$skipped$top10$reason, "dominated by a single extreme-precision observation")
  expect_false("top10" %in% res$coefficients$model)
})

test_that("STEM is skipped when it lands on its algorithmic minimum window", {
  set.seed(123)
  n <- 30
  se <- seq(0.02, 0.3, length.out = n)
  effect <- c(0.9, 0.95, 0.85, rnorm(n - 3, 0, 0.05))
  df <- data.frame(
    effect = effect,
    se = se,
    study_id = paste0("S", seq_len(n)),
    stringsAsFactors = FALSE
  )

  res <- suppressWarnings(run_nonlinear_methods(df, make_degenerate_options()))

  expect_true("stem" %in% names(res$skipped))
  expect_match(res$skipped$stem$reason, "corner solution")
  expect_false("stem" %in% res$coefficients$model)
})

test_that("Selection model is skipped on a boundary (non-converged) solution", {
  set.seed(42)
  n <- 60
  df <- data.frame(
    effect = rnorm(n, 0, 0.001),
    se = rep(1, n),
    study_id = paste0("S", seq_len(n)),
    stringsAsFactors = FALSE
  )

  res <- suppressWarnings(run_nonlinear_methods(df, make_degenerate_options()))

  expect_true("selection" %in% names(res$skipped))
  expect_false("selection" %in% res$coefficients$model)
})

test_that("nonlinear methods record skipped models when input is insufficient", {
  df <- data.frame(
    effect = 0.15,
    se = 0.2,
    study_id = "S1",
    stringsAsFactors = FALSE
  )
  options <- list(
    add_significance_marks = FALSE,
    round_to = 3L,
    stem_representative_sample = "medians",
    selection_cutoffs = c(1.96),
    selection_symmetric = FALSE,
    selection_model = "normal",
    hierarchical_iterations = 10L
  )

  res <- run_nonlinear_methods(df, options)

  expect_equal(nrow(res$coefficients), 0L)
  expect_equal(nrow(res$summary), 0L)
  expect_named(
    res$skipped,
    c("waap", "top10", "stem", "hierarchical", "selection", "endogenous_kink")
  )
  expect_true(all(vapply(res$skipped, function(entry) {
    is.list(entry) && is.character(entry$reason) && nzchar(entry$reason)
  }, logical(1))))
  expect_equal(res$options, options)
})

Try the artma package in your browser

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

artma documentation built on Oct. 27, 2026, 9:06 a.m.