R/ld_partgam.R

Defines functions RMlocdepGammaPlot run_partgam_LD_sim_sequential run_partgam_LD_sim_parallel run_single_partgam_LD_sim .partgam_strata_summary .partgam_one .partgam_ld_gamma RMlocdepGammaCutoff knit_print.RMlocdepGamma print.RMlocdepGamma RMlocdepGamma

Documented in knit_print.RMlocdepGamma print.RMlocdepGamma RMlocdepGamma RMlocdepGammaCutoff RMlocdepGammaPlot run_partgam_LD_sim_parallel run_partgam_LD_sim_sequential run_single_partgam_LD_sim

#' Partial Gamma Local Dependence Analysis
#'
#' Computes partial gamma coefficients for Local Dependence (LD) assessment
#' using \code{iarm::partgam_LD()}. Each pair of items is tested for residual
#' association, controlling for the rest score (total score minus one of the
#' items in the pair).
#'
#' @param data A data.frame or matrix of item responses. Items must be scored
#'   starting at 0 (non-negative integers). Missing values (`NA`) are allowed,
#'   but at least one complete case must exist.
#' @param cutoff Optional. Default `NULL` (no cutoff applied). Can be:
#'   * The return value of \code{\link{RMlocdepGammaCutoff}} (a list with
#'     `$pair_cutoffs`): the data.frame is extracted automatically and
#'     simulation metadata is included in the kable caption.
#'   * The `$pair_cutoffs` data.frame from \code{\link{RMlocdepGammaCutoff}}
#'     directly: must have columns `Item1`, `Item2`, `gamma_low`, `gamma_high`.
#'   When provided, adds columns `Gamma_low`, `Gamma_high`, and `Flagged`
#'   (logical; `TRUE` when the observed partial gamma falls outside the
#'   credible range) to the result.
#' @param p_value Logical or `NULL`. When `TRUE`, adds one-sided bootstrap
#'   p-values for *excess positive* local dependence (`p_gamma`,
#'   `padj_gamma`), matching the `p_value` semantics of
#'   \code{\link{RMlocdepQ3}}, and `flagged` reflects `padj_gamma < alpha`
#'   (positive deviations only) instead of the credible range. One test per
#'   item pair: the p-value is computed on `gamma_pair`, the larger of the two
#'   conditioning directions, and repeated in the direction-2 table for the
#'   same pair. The asymptotic adjusted p-value and star columns from
#'   `iarm::partgam_LD()` are **dropped** in this mode, and the simulated
#'   `gamma_low` / `gamma_high` band is kept as the effect-size reference.
#'   `NULL`, the default, means `TRUE` when `cutoff` is the **full**
#'   \code{\link{RMlocdepGammaCutoff}} object (it carries the simulated
#'   distributions in `$results`) and `FALSE` otherwise, so calling with no
#'   cutoff keeps the asymptotic table unchanged. Pass `FALSE` for the
#'   pre-1.2.0 behaviour. The interval makes a poor decision rule, since its
#'   width sets a family-wise error rate of `1 - width^m` over all \eqn{m}
#'   pairs at once (Johansson, 2026).
#' @param correction Character. Multiplicity correction for the bootstrap
#'   p-values, applied over the family of all item pairs (before any
#'   `n_pairs` display filter): `"fwer"` (default; Westfall-Young
#'   studentised-max step-down), `"fdr_bh"`, `"fdr_by"`, or `"none"`. Ignored
#'   when `p_value = FALSE`.
#' @param alpha Numeric in (0, 1). Significance level used to flag pairs on
#'   the corrected p-value. Default `0.05`. Ignored when `p_value = FALSE`.
#' @param output Character string controlling the return value. Either
#'   `"kable"` (default) for a formatted `knitr::kable()` table, or
#'   `"dataframe"` for the underlying data.frame.
#' @param n_pairs Optional positive integer. When supplied, only the
#'   `n_pairs` item pairs with the largest absolute partial-gamma values
#'   (i.e., strongest residual dependence in either direction) are
#'   retained per rest-score direction, sorted by `|gamma|` descending.
#'   When `NULL` (default), all pairs are returned in `iarm`'s native
#'   ordering. Values larger than the total number of pairs are silently
#'   capped at that total.
#'
#' @return
#' * If `output = "kable"`: an object of class `"RMlocdepGamma"`. Internally
#'   a list with two `knitr_kable` elements, `$direction1` and
#'   `$direction2`. In both, the rest score is the total score minus
#'   Item 2 (the second column); the two elements list each item pair in
#'   the two possible orders, so together they cover both rest-score
#'   directions for every pair. Each has columns "Item 1", "Item 2",
#'   "Partial gamma",
#'   "Adj. p-value (BH)", and "p-value sign." (a star-string indicator
#'   from `iarm::partgam_LD()`). When `cutoff` is provided, additional
#'   columns "Gamma low", "Gamma high", and "Flagged" are included.
#'
#'   The object has custom `print()` and `knitr::knit_print()` methods:
#'   in the R console it prints the two tables stacked vertically; in a
#'   Quarto / R Markdown chunk it renders as two distinct pipe tables.
#'   Access the individual tables explicitly as `result$direction1` and
#'   `result$direction2` if needed.
#' * If `output = "dataframe"`: a named list of two data.frames
#'   (`$direction1`, `$direction2`) with columns `Item1`, `Item2`,
#'   `gamma`, `se`, `lower`, `upper` (95% Wald CI), `padj_bh`,
#'   `Significance`. When `cutoff` is provided, columns `gamma_low`,
#'   `gamma_high`, and `flagged` are also included.
#'   With `p_value = TRUE`, `padj_bh` and `Significance` are replaced by
#'   `p_gamma` and `padj_gamma` (identical for a pair in both directions).
#'
#' @details
#' Partial gamma (Christensen, Kreiner & Mesbah, 2013) measures the residual
#' association between pairs of items after controlling for the rest score
#' (total score minus one item). Because it matters which item is subtracted,
#' calculations are done for each pair in both directions, yielding two
#' data.frames.
#'
#' Values near 0 indicate no local dependence. Large positive values suggest
#' positive LD (items share variance beyond the latent trait), while large
#' negative values suggest negative LD.
#'
#' The `iarm` package must be installed (it is in Suggests, not Imports).
#'
#' \strong{Bootstrap p-values.} When `p_value = TRUE`, each pair is tested
#' once, on the larger of its two rest-score directions (the `gamma_pair`
#' column), against its simulated null distribution (from `cutoff$results`,
#' simulated under local independence and built from that same maximum).
#' The per-pair statistic is the residual studentised by the bootstrap mean
#' and SD; the marginal p-value is the one-sided Monte-Carlo p-value
#' `(1 + #\{t* >= t\}) / (B + 1)` for excess *positive* LD (redundancy, the
#' diagnostic target — matching \code{\link{RMlocdepQ3}}), so it can be no
#' smaller than `1 / (B + 1)`. The band still shows both bounds for
#' reference. `correction = "fwer"` uses the Westfall-Young studentised-max
#' step-down over the family of all pairs, which exploits the bootstrap
#' dependence among them (Ferreira, 2024); it is liberal when the simulation
#' is small, so at least 1000 `iterations` in [RMlocdepGammaCutoff()] are
#' recommended (a warning is issued below that). Unlike the asymptotic
#' p-values from `iarm::partgam_LD()`, these are calibrated against the
#' *simulated Rasch null* rather than the asymptotic SE; they are
#' model-conditional and sample-size-sensitive, and are reported alongside
#' the simulated effect-size band, not in place of it.
#'
#' @inheritSection RMitemInfit Multiple comparisons
#'
#' @references
#' Christensen, K. B., Kreiner, S. & Mesbah, M. (Eds.) (2013).
#' \emph{Rasch Models in Health}, pp. 133--135. ISTE & Wiley.
#' \doi{10.1002/9781118574454}
#'
#' Ferreira, J. A. (2024). Methods of testing a 'small' or 'moderate' number
#' of hypotheses simultaneously. *Journal of Statistical Theory and Practice,
#' 19*(6). \doi{10.1007/s42519-024-00412-4}
#'
#' Westfall, P. H., & Young, S. S. (1993). *Resampling-Based Multiple Testing*.
#' Wiley.
#'
#' @seealso \code{\link{RMlocdepGammaCutoff}}, \code{\link{RMlocdepGammaPlot}}
#'
#' @export
#'
#' @examples
#' \donttest{
#' if (requireNamespace("iarm", quietly = TRUE)) {
#'   set.seed(42)
#'   sim_data <- as.data.frame(
#'     matrix(sample(0:1, 200 * 10, replace = TRUE), nrow = 200, ncol = 10)
#'   )
#'   colnames(sim_data) <- paste0("Item", 1:10)
#'
#'   # Default kable output
#'   RMlocdepGamma(sim_data)
#'
#'   # Return as data.frame list
#'   RMlocdepGamma(sim_data, output = "dataframe")
#'
#'   # Simulation-based cutoffs (slow): 100+ Monte-Carlo iterations
#'   if (requireNamespace("ggdist", quietly = TRUE)) {
#'     cutoff_res <- RMlocdepGammaCutoff(sim_data, iterations = 100,
#'                                       parallel = FALSE, seed = 42)
#'     RMlocdepGamma(sim_data, cutoff = cutoff_res)
#'
#'     # Bootstrap p-values with family-wise (Westfall-Young) correction
#'     # (use iterations >= 1000 in real analyses for stable p-values)
#'     RMlocdepGamma(sim_data, cutoff = cutoff_res, p_value = TRUE,
#'                   output = "dataframe")
#'   }
#' }
#' }
RMlocdepGamma <- function(
  data,
  cutoff = NULL,
  p_value = NULL,
  correction = c("fwer", "fdr_bh", "fdr_by", "none"),
  alpha = 0.05,
  output = "kable",
  n_pairs = NULL
) {
  if (!requireNamespace("iarm", quietly = TRUE)) {
    stop(
      "Package 'iarm' is required for RMlocdepGamma() but is not installed.\n",
      "Install it with: install.packages(\"iarm\")",
      call. = FALSE
    )
  }

  output <- match.arg(output, c("kable", "dataframe"))
  correction <- match.arg(correction)
  if (!is.numeric(alpha) || length(alpha) != 1L || alpha <= 0 || alpha >= 1) {
    stop("`alpha` must be a single number in (0, 1).", call. = FALSE)
  }

  # --- Validate n_pairs -------------------------------------------------------
  if (!is.null(n_pairs)) {
    if (
      !is.numeric(n_pairs) ||
        length(n_pairs) != 1L ||
        !is.finite(n_pairs) ||
        n_pairs < 1 ||
        n_pairs != as.integer(n_pairs)
    ) {
      stop(
        "`n_pairs` must be a single positive integer or NULL.",
        call. = FALSE
      )
    }
    n_pairs <- as.integer(n_pairs)
  }

  validate_response_data(data)

  # --- Validate and normalise cutoff ------------------------------------------
  cutoff_n_iter <- NULL
  cutoff_method <- NULL
  cutoff_hdci_width <- NULL
  cutoff_full <- NULL # full object (carries simulated $results for p-values)
  if (!is.null(cutoff)) {
    if (
      is.list(cutoff) &&
        !is.data.frame(cutoff) &&
        "pair_cutoffs" %in% names(cutoff)
    ) {
      cutoff_full <- cutoff
      cutoff_n_iter <- cutoff$actual_iterations
      cutoff_method <- cutoff$cutoff_method
      cutoff_hdci_width <- cutoff$hdci_width
      cutoff <- cutoff$pair_cutoffs
    }
    if (!is.data.frame(cutoff)) {
      stop(
        "`cutoff` must be NULL, the return value of RMlocdepGammaCutoff(), or its ",
        "$pair_cutoffs data.frame.",
        call. = FALSE
      )
    }
    required_cols <- c("Item1", "Item2", "gamma_low", "gamma_high")
    missing_cols <- setdiff(required_cols, names(cutoff))
    if (length(missing_cols) > 0L) {
      stop(
        "`cutoff` data.frame is missing required columns: ",
        paste(missing_cols, collapse = ", "),
        ".",
        call. = FALSE
      )
    }
  }

  # --- Resolve p_value --------------------------------------------------------
  # NULL means "use the corrected p-value when the simulations are available".
  # The interval describes where a pair's coefficient is expected to fall and
  # makes a poor decision rule, because its width sets a family-wise error rate
  # over every pair at once (Johansson, 2026). The bare $pair_cutoffs, or no
  # cutoff at all, leaves nothing to compute a p-value from and resolves to
  # FALSE, which keeps the asymptotic path this function shows by default
  # exactly as it was.
  if (!is.null(p_value) && (!is.logical(p_value) || length(p_value) != 1L)) {
    stop("`p_value` must be TRUE, FALSE, or NULL.", call. = FALSE)
  }
  have_sims <- !is.null(cutoff_full) && !is.null(cutoff_full$results)
  if (is.null(p_value)) {
    p_value <- have_sims
  }
  n_pairs_total <- if (!is.null(cutoff_full$pair_cutoffs)) {
    nrow(cutoff_full$pair_cutoffs)
  } else {
    NULL
  }

  if (p_value) {
    if (!have_sims) {
      stop(
        "`p_value = TRUE` requires the full RMlocdepGammaCutoff() object (it ",
        "carries the simulated per-pair distributions in $results); a NULL ",
        "cutoff or the bare $pair_cutoffs data.frame is not sufficient.",
        call. = FALSE
      )
    }
    # Below 400 the correction itself is off. Between 400 and 1000 only
    # reproducibility improves, which the table caption reports instead.
    if (!is.null(cutoff_n_iter) && cutoff_n_iter < 400L) {
      .notify_low_iterations(
        cutoff_n_iter,
        cutoff_full$requested_iterations,
        fn = "RMlocdepGammaCutoff()",
        id = "easyRasch2_low_iterations_locdep"
      )
    }
    if (!is.null(n_pairs_total)) {
      .warn_fdr_floor(
        cutoff_n_iter,
        n_pairs_total,
        correction,
        alpha,
        unit = "item pairs",
        fn = "RMlocdepGammaCutoff()"
      )
    }
  } else if (!is.null(cutoff_full) && !is.null(n_pairs_total)) {
    .notify_band_flagging(
      if (identical(cutoff_method, "quantile")) 0.95 else cutoff_hdci_width,
      n_pairs_total,
      unit = "item pairs",
      fn = "RMlocdepGammaCutoff()",
      id = "easyRasch2_band_flagging_locdep"
    )
  }

  # --- rgl workaround ---------------------------------------------------------
  old_rgl <- getOption("rgl.useNULL")
  options(rgl.useNULL = TRUE)
  on.exit(options(rgl.useNULL = old_rgl), add = TRUE)

  # --- Compute partial gamma LD via iarm --------------------------------------
  sink(nullfile())
  pgam_raw <- iarm::partgam_LD(as.data.frame(data))
  sink()

  # pgam_raw is a list of two data.frames (one per rest-score direction).
  # Columns from iarm::partgam_LD():
  #   1 Item1, 2 Item2, 3 gamma, 4 se, 5 pvalue, 6 padj.BH, 7 sig,
  #   8 lower, 9 upper. The `sig` column is a star string like " ***" /
  #   " **" / " *" / "  ." / "    "; we trim whitespace for display.
  process_pgam_df <- function(raw_df) {
    df <- data.frame(
      Item1 = as.character(raw_df$Item1),
      Item2 = as.character(raw_df$Item2),
      gamma = as.numeric(raw_df$gamma),
      se = as.numeric(raw_df$se),
      lower = as.numeric(raw_df$lower),
      upper = as.numeric(raw_df$upper),
      padj_bh = as.numeric(raw_df[[6]]),
      Significance = trimws(as.character(raw_df[[7]])),
      stringsAsFactors = FALSE
    )
    df
  }

  result_list <- list(
    direction1 = process_pgam_df(pgam_raw[[1]]),
    direction2 = process_pgam_df(pgam_raw[[2]])
  )

  # --- The tested pair statistic ----------------------------------------------
  # A pair is tested once, on the larger of its two conditioning directions,
  # because local dependence violates both of the conditional independence
  # hypotheses the Rasch model implies for it. The `gamma` column of each table
  # remains that direction's own coefficient, which is what a reader wants to
  # see, but every decision below is taken on `gamma_pair`, so no p-value or
  # flag is ever attributed to a coefficient it was not computed from.
  data_complete <- data[stats::complete.cases(data), , drop = FALSE]
  .obs1 <- .partgam_ld_gamma(data_complete, direction = 1L)
  .obs2 <- .partgam_ld_gamma(data_complete, direction = 2L)
  .pkey <- function(a, b) paste(pmin(a, b), pmax(a, b), sep = "___")
  gamma_pair <- stats::setNames(
    pmax(.obs1$gamma,
         .obs2$gamma[match(.pkey(.obs1$Item1, .obs1$Item2),
                           .pkey(.obs2$Item1, .obs2$Item2))]),
    .pkey(.obs1$Item1, .obs1$Item2)
  )
  for (idx in seq_along(result_list)) {
    df <- result_list[[idx]]
    df$gamma_pair <- as.numeric(gamma_pair[.pkey(df$Item1, df$Item2)])
    result_list[[idx]] <- df
  }

  # --- Apply cutoff if provided -----------------------------------------------
  # Cutoffs are keyed by direction-1 pairs (i < j), so for direction 2 (i > j)

  # we must look up by the canonical (sorted) pair order.
  if (!is.null(cutoff)) {
    # Build a canonical key on the cutoff table
    cutoff$canonical_key <- paste(
      pmin(cutoff$Item1, cutoff$Item2),
      pmax(cutoff$Item1, cutoff$Item2),
      sep = "___"
    )
    cutoff_sub <- cutoff[, c("canonical_key", "gamma_low", "gamma_high")]

    for (idx in seq_along(result_list)) {
      result_df <- result_list[[idx]]
      # Build the same canonical key on the result (sorted pair order)
      result_df$canonical_key <- paste(
        pmin(result_df$Item1, result_df$Item2),
        pmax(result_df$Item1, result_df$Item2),
        sep = "___"
      )

      merged <- merge(
        result_df,
        cutoff_sub,
        by = "canonical_key",
        all.x = TRUE,
        sort = FALSE
      )
      # Restore original row order
      merged <- merged[match(result_df$canonical_key, merged$canonical_key), ]
      rownames(merged) <- NULL
      # Flagged on the pair statistic, which is what the cutoff band describes.
      merged$flagged <- !is.na(merged$gamma_low) &
        (merged$gamma_pair < merged$gamma_low |
           merged$gamma_pair > merged$gamma_high)

      # Remove helper column
      merged$canonical_key <- NULL

      merged <- merged[, c(
        "Item1",
        "Item2",
        "gamma",
        "gamma_pair",
        "se",
        "lower",
        "upper",
        "padj_bh",
        "Significance",
        "gamma_low",
        "gamma_high",
        "flagged"
      )]
      result_list[[idx]] <- merged
    }
    # Clean up cutoff helper column
    cutoff$canonical_key <- NULL
  }

  # --- Bootstrap p-values (one test per pair) ---------------------------------
  # Computed BEFORE the n_pairs display filter so the multiplicity correction
  # always runs over the full family of pairs. Both the observed statistic and
  # the simulated null are the larger of a pair's two rest-score directions, so
  # the two are the same functional; the resulting p-value is keyed by the
  # unordered pair and repeated in the direction-2 table.
  if (p_value) {
    canon_key <- function(a, b) {
      paste(pmin(a, b), pmax(a, b), sep = "___")
    }
    sim_res <- cutoff_full$results
    sim_key <- canon_key(sim_res$Item1, sim_res$Item2)
    obs_key <- canon_key(
      as.character(pgam_raw[[1L]]$Item1),
      as.character(pgam_raw[[1L]]$Item2)
    )
    if (!setequal(obs_key, unique(sim_key))) {
      stop(
        "Item pairs in the cutoff simulations ($results) do not match the ",
        "pairs in `data`.",
        call. = FALSE
      )
    }
    sim_mat <- tapply(
      sim_res$gamma,
      list(sim_res$iteration, sim_key),
      function(x) x[1L]
    )
    # The tested statistic is the pair maximum computed above, from the same
    # code path that produced the simulated null, so the two cannot diverge.
    observed <- gamma_pair[obs_key]
    # One-sided: excess positive LD (redundancy), matching RMlocdepQ3.
    pv <- .bootstrap_pvalues(
      observed,
      sim_mat,
      correction = correction,
      tail = "upper"
    )
    p_lookup <- stats::setNames(pv$p, pv$name)
    padj_lookup <- stats::setNames(pv$padj, pv$name)

    for (idx in seq_along(result_list)) {
      df <- result_list[[idx]]
      key <- canon_key(df$Item1, df$Item2)
      df$p_gamma <- as.numeric(p_lookup[key])
      df$padj_gamma <- as.numeric(padj_lookup[key])
      df$flagged <- !is.na(df$padj_gamma) & df$padj_gamma < alpha
      # Drop the asymptotic p-value pair; the bootstrap p-values replace it.
      df <- df[, c(
        "Item1",
        "Item2",
        "gamma",
        "gamma_pair",
        "se",
        "lower",
        "upper",
        "gamma_low",
        "gamma_high",
        "p_gamma",
        "padj_gamma",
        "flagged"
      )]
      result_list[[idx]] <- df
    }
  }

  # --- Top-N filter by |gamma| per direction ----------------------------------
  total_pairs <- nrow(result_list[[1L]])
  filter_applied <- FALSE
  if (!is.null(n_pairs)) {
    keep_n <- min(n_pairs, total_pairs)
    if (keep_n < total_pairs) {
      filter_applied <- TRUE
      for (idx in seq_along(result_list)) {
        df <- result_list[[idx]]
        ord <- order(abs(df$gamma), decreasing = TRUE)
        df <- df[ord[seq_len(keep_n)], , drop = FALSE]
        rownames(df) <- NULL
        result_list[[idx]] <- df
      }
    }
  }

  # --- Return -----------------------------------------------------------------
  if (output == "dataframe") {
    return(result_list)
  }

  # Kable display rounding (the dataframe output above stays unrounded).
  # The se / lower / upper columns are dataframe-only (added for
  # programmatic use, e.g. the jamovi module); the kable keeps its
  # previous column set.
  ld_digits <- c(
    gamma = 3, padj_bh = 3, gamma_low = 3, gamma_high = 3,
    p_gamma = 4, padj_gamma = 4, gamma_pair = 3
  )
  result_list <- lapply(result_list, function(d) {
    .round_display(d[, setdiff(names(d), c("se", "lower", "upper")),
                     drop = FALSE], digits = ld_digits)
  })

  # Build caption
  n_complete <- sum(stats::complete.cases(as.data.frame(data)))
  n_clause <- .n_caption(
    n_complete,
    nrow(as.data.frame(data)),
    if (anyNA(as.data.frame(data))) "complete cases" else character()
  )
  filter_suffix <- if (filter_applied) {
    paste0(" Showing top ", n_pairs, " of ", total_pairs, " pairs by |gamma|.")
  } else {
    ""
  }
  if (p_value) {
    caption_text <- paste0(
      "Partial gamma LD analysis. ",
      n_clause,
      ". One-sided bootstrap p-values for excess positive LD from ",
      cutoff_n_iter,
      " iterations, computed on Gamma pair (max), the larger of the pair's ",
      "two conditioning directions, and repeated across both tables ",
      "(replacing the asymptotic p-values). Multiplicity correction: ",
      .correction_label(correction),
      ". Flagged at padj < ",
      alpha,
      ". p-values cannot be smaller than 1/(",
      cutoff_n_iter,
      "+1) = ",
      round(1 / (cutoff_n_iter + 1), 4),
      ". The interval is shown as description and is not the decision rule, ",
      "so a pair below the lower bound is not flagged.",
      .iteration_note(cutoff_n_iter),
      .attrition_clause(cutoff_n_iter, cutoff_full$requested_iterations),
      filter_suffix
    )
  } else if (is.null(cutoff)) {
    caption_text <- paste0(
      "Partial gamma LD analysis. ",
      n_clause,
      ". Positive gamma indicates positive local dependence between items.",
      filter_suffix
    )
  } else if (!is.null(cutoff_n_iter)) {
    method_label <- .format_gamma_cutoff_method_label(
      cutoff_method,
      cutoff_hdci_width
    )
    iter_part <- paste0(cutoff_n_iter, " simulation iterations")
    caption_text <- paste0(
      "Partial gamma LD analysis. ",
      n_clause,
      ". Cutoff values based on ",
      if (!is.null(method_label)) {
        paste0(iter_part, " (", method_label, ").")
      } else {
        paste0(iter_part, ".")
      },
      .band_error_clause(
        if (identical(cutoff_method, "quantile")) 0.95 else cutoff_hdci_width,
        total_pairs,
        unit = "item pairs"
      ),
      .attrition_clause(cutoff_n_iter, cutoff_full$requested_iterations),
      filter_suffix
    )
  } else {
    caption_text <- paste0(
      "Partial gamma LD analysis. ",
      n_clause,
      ". Simulation-based cutoff values applied.",
      filter_suffix
    )
  }

  # One header per displayed column, in the order the tables carry them. The
  # `gamma_pair` column (the larger of the two conditioning directions, and the
  # statistic that is tested) sits fourth once a cutoff is supplied and last on
  # the asymptotic path, so the three vectors are not interchangeable.
  col_names_no_cutoff <- c(
    "Item 1",
    "Item 2",
    "Partial gamma",
    "Adj. p-value (BH)",
    "p-value sign.",
    "Gamma pair (max)"
  )
  col_names_cutoff <- c(
    "Item 1",
    "Item 2",
    "Partial gamma",
    "Gamma pair (max)",
    "Adj. p-value (BH)",
    "p-value sign.",
    "Gamma low",
    "Gamma high",
    "Flagged"
  )
  col_names_pvalue <- c(
    "Item 1",
    "Item 2",
    "Partial gamma",
    "Gamma pair (max)",
    "Gamma low",
    "Gamma high",
    "p",
    "p (adj)",
    "Flagged"
  )
  col_names_used <- if (p_value) {
    col_names_pvalue
  } else if (is.null(cutoff)) {
    col_names_no_cutoff
  } else {
    col_names_cutoff
  }

  kable1 <- knitr::kable(
    result_list$direction1,
    format = "pipe",
    col.names = col_names_used,
    caption = paste0(caption_text, " Direction 1: rest score = total - Item2.")
  )

  kable2 <- knitr::kable(
    result_list$direction2,
    format = "pipe",
    col.names = col_names_used,
    caption = paste0(
      caption_text,
      " Direction 2: rest score = total - Item2 ",
      "(item pairs shown in the reverse order to ",
      "direction 1)."
    )
  )

  # `knitr::kable(format = "pipe")` returns a multi-line *character vector*
  # (one element per line), so `paste(kable1, "\n\n", kable2)` would do
  # element-wise concatenation, interleaving the two tables row-by-row.
  # Collapse each table to a single string first, then join with a
  # blank line between to render as two distinct kable tables.
  combined <- paste(
    paste(kable1, collapse = "\n"),
    paste(kable2, collapse = "\n"),
    sep = "\n\n"
  )

  # Return a custom-class list so the two display contexts (R console and
  # knitr chunk) can be handled separately. See print.RMlocdepGamma() and
  # knit_print.RMlocdepGamma() below.
  out <- list(
    direction1 = kable1,
    direction2 = kable2,
    .combined = combined
  )
  class(out) <- c("RMlocdepGamma", "list")
  out
}

#' Print method for RMlocdepGamma kable output
#'
#' Prints the two rest-score direction tables stacked vertically with a
#' blank line between them. Each table renders via `knitr_kable`'s own
#' print method as a clean pipe-markdown table.
#'
#' @param x An object of class `"RMlocdepGamma"` returned by
#'   \code{\link{RMlocdepGamma}} with `output = "kable"`.
#' @param ... Further arguments (currently unused).
#' @return Invisibly returns `x`.
#' @keywords internal
#' @exportS3Method base::print
print.RMlocdepGamma <- function(x, ...) {
  print(x$direction1)
  cat("\n")
  print(x$direction2)
  invisible(x)
}

#' knitr knit_print method for RMlocdepGamma kable output
#'
#' Inside a knitr / Quarto / R Markdown chunk, returns the pre-combined
#' two-table asis string so pandoc renders them as two distinct pipe
#' tables. Outside knitr, R's normal dispatch falls back to
#' `print.RMlocdepGamma()`.
#'
#' @param x An object of class `"RMlocdepGamma"`.
#' @param ... Further arguments passed to `knitr::asis_output()`.
#' @return A `knit_asis` character object.
#' @keywords internal
#' @exportS3Method knitr::knit_print
knit_print.RMlocdepGamma <- function(x, ...) {
  knitr::asis_output(x$.combined)
}

#' Simulation-Based Partial Gamma LD Cutoff Determination
#'
#' Uses parametric bootstrap simulation to determine appropriate cutoff values
#' for partial gamma Local Dependence analysis via
#' \code{\link[iarm]{partgam_LD}}. Under a correctly fitting Rasch model where
#' items are locally independent, this function generates the expected
#' distribution of partial gamma values per item pair, providing empirical
#' critical values.
#'
#' @param data A data.frame or matrix of item responses. Items must be scored
#'   starting at 0 (non-negative integers). Only complete cases (rows without
#'   any `NA`) are used.
#' @param iterations Integer. Number of simulation iterations (default 400,
#'   was 250 before 1.2.0). 400 is the calibrated floor for the Westfall-Young
#'   correction (Johansson, 2026) and the count a 95\% interval needs to
#'   converge. Use 1000 to 2000 for a final analysis.
#' @param parallel Logical. Use parallel processing via `mirai` if available
#'   (default `TRUE`).
#' @param n_cores Integer or `NULL`. Number of parallel workers. When `NULL`,
#'   `getOption("mc.cores")` is checked first. If neither is set and
#'   `parallel = TRUE`, a warning is issued and execution falls back to
#'   sequential (single core) processing.
#' @param verbose Logical. Show a progress bar (default `FALSE`).
#' @param seed Integer or `NULL`. Random seed for reproducibility. See
#'   [easyRasch2-reproducibility] for what this guarantees and how it
#'   interacts with `parallel`.
#' @param cutoff_method Character string specifying how cutoff intervals are
#'   computed. Either `"hdci"` (default) for the Highest Density Interval via
#'   `ggdist::hdci()`, or `"quantile"` for the 2.5th/97.5th percentiles via
#'   `stats::quantile()`.
#' @param hdci_width Numeric. Width of the HDCI when `cutoff_method = "hdci"`.
#'   Default is `0.95` (95\% HDCI), was `0.99` before 1.2.0. The interval
#'   describes where a fitting pair's coefficient is expected to fall and is
#'   no longer the default decision rule, so the width is chosen to converge
#'   at the default iteration count rather than to imply an error rate.
#'   Ignored when `cutoff_method = "quantile"`.
#'
#' @return A list with components:
#' \describe{
#'   \item{`results`}{data.frame with columns `iteration`, `Item1`, `Item2`,
#'     and `gamma` (one row per item pair per successful iteration). `gamma` is
#'     the larger of the pair's two rest-score directions, the statistic
#'     `RMlocdepGamma()` tests. Rows are keyed by the sorted pair order, with
#'     `Item1` before `Item2` in column order.}
#'   \item{`pair_cutoffs`}{data.frame with per-pair cutoff summaries: `Item1`,
#'     `Item2`, `gamma_low`, `gamma_high`. Bounds are computed using the method
#'     specified by `cutoff_method`.}
#'   \item{`actual_iterations`}{Number of successful iterations.}
#'   \item{`sample_n`}{Number of complete cases used.}
#'   \item{`sample_n_total`}{Number of respondents in the raw input data,
#'     before the complete-case filter.}
#'   \item{`sample_has_na`}{Logical. Whether the raw input data contained
#'     any missing values.}
#'   \item{`sample_summary`}{Summary statistics of estimated person
#'     parameters.}
#'   \item{`item_names`}{Character vector of item names from data.}
#'   \item{`cutoff_method`}{The method used to compute cutoffs (`"hdci"` or
#'     `"quantile"`).}
#'   \item{`hdci_width`}{The HDCI width used (only meaningful when
#'     `cutoff_method = "hdci"`).}
#' }
#'
#' @details
#' For each simulation iteration the function:
#' \enumerate{
#'   \item Resamples person parameters (thetas) with replacement from the
#'     WLE person locations.
#'   \item Simulates item response data under a Rasch model (dichotomous via
#'     `psychotools::rrm()` or polytomous via an internal partial credit
#'     simulator).
#'   \item Computes partial gamma for every item pair in both rest-score
#'     directions and keeps the larger. The coefficients are identical to
#'     those of `iarm::partgam_LD()`, but are computed by a vectorised
#'     internal, since `iarm` also derives the asymptotic standard error and
#'     confidence interval that a simulated null does not need and costs
#'     roughly two orders of magnitude more per iteration.
#' }
#'
#' Because the data are simulated under the Rasch model, items are locally
#' independent by construction. The distribution of partial gamma values
#' across iterations provides empirical critical values per item pair. Values
#' from real data that fall outside these bounds suggest local dependence that
#' exceeds what would be expected by chance. Failed iterations (e.g., due to
#' convergence issues or degenerate data) are silently discarded.
#'
#' The generating model uses CML item thresholds via `psychotools::pcmodel()`
#' (a dichotomous item is a 2-category PCM) and WLE person locations,
#' consistent with the rest of the package; responses are simulated with
#' `psychotools::rrm()` (dichotomous) or an internal partial credit score
#' simulator (polytomous).
#'
#' Parallel processing is provided by the `mirai` package (optional). Install
#' it with `install.packages("mirai")` to enable parallelisation.
#'
#' The `iarm` package must be installed (it is in Suggests, not Imports).
#'
#' @references
#' Christensen, K. B., Kreiner, S. & Mesbah, M. (Eds.) (2013).
#' \emph{Rasch Models in Health}, pp. 133--135. ISTE & Wiley.
#' \doi{10.1002/9781118574454}
#'
#' @seealso \code{\link[iarm]{partgam_LD}}, \code{\link{RMlocdepGamma}},
#'   \code{\link{RMlocdepGammaPlot}}
#'
#' @export
#'
#' @examples
#' \donttest{
#' if (requireNamespace("iarm", quietly = TRUE) &&
#'     requireNamespace("ggdist", quietly = TRUE)) {
#'   set.seed(42)
#'   sim_data <- as.data.frame(
#'     matrix(sample(0:1, 200 * 10, replace = TRUE), nrow = 200, ncol = 10)
#'   )
#'   colnames(sim_data) <- paste0("Item", 1:10)
#'
#'   # Run 100 iterations sequentially for a quick demo
#'   cutoff_res <- RMlocdepGammaCutoff(sim_data, iterations = 100,
#'                                     parallel = FALSE, seed = 42)
#'   cutoff_res$pair_cutoffs
#' }
#' }
RMlocdepGammaCutoff <- function(
  data,
  iterations = 400,
  parallel = TRUE,
  n_cores = NULL,
  verbose = FALSE,
  seed = NULL,
  cutoff_method = "hdci",
  hdci_width = 0.95
) {
  cutoff_method <- match.arg(cutoff_method, c("hdci", "quantile"))

  if (cutoff_method == "hdci" && !requireNamespace("ggdist", quietly = TRUE)) {
    stop(
      "Package 'ggdist' is required when cutoff_method = \"hdci\" but is not installed.\n",
      "Install it with: install.packages(\"ggdist\")\n",
      "Alternatively, use cutoff_method = \"quantile\" to avoid this dependency.",
      call. = FALSE
    )
  }

  if (!requireNamespace("iarm", quietly = TRUE)) {
    stop(
      "Package 'iarm' is required for RMlocdepGammaCutoff() but is not installed.\n",
      "Install it with: install.packages(\"iarm\")",
      call. = FALSE
    )
  }

  validate_response_data(data)

  # rgl workaround (iarm depends on vcdExtra -> rgl)
  old_rgl <- getOption("rgl.useNULL")
  options(rgl.useNULL = TRUE)
  on.exit(options(rgl.useNULL = old_rgl), add = TRUE)

  # Only complete cases. Record the raw total and whether anything was
  # dropped so callers (e.g. RMlocdepGammaPlot) can report the sample in the
  # standard `n = X of Y respondents` form.
  n_total <- nrow(data)
  has_na <- anyNA(data)
  complete_idx <- stats::complete.cases(data)
  data <- data[complete_idx, , drop = FALSE]

  if (nrow(data) == 0L) {
    stop(
      "No complete cases in data after removing rows with NA.",
      call. = FALSE
    )
  }

  # --- Parallel setup ---------------------------------------------------------
  use_parallel <- parallel && requireNamespace("mirai", quietly = TRUE)

  if (parallel && !use_parallel) {
    message(
      "Install 'mirai' package for parallel processing: install.packages(\"mirai\")"
    )
    message("Running sequentially...")
  }

  if (use_parallel) {
    if (is.null(n_cores)) {
      n_cores <- getOption("mc.cores")
    }
    if (is.null(n_cores)) {
      warning(
        paste0(
          "For parallel processing, specify n_cores or set options(mc.cores = N).\n",
          "(Use `parallel::detectCores()` to see how many cores are available.)\n",
          "Falling back to sequential (single core) processing."
        ),
        call. = FALSE
      )
      use_parallel <- FALSE
    } else {
      n_cores <- min(n_cores, iterations)
    }
  }

  if (!is.null(seed)) {
    set.seed(seed)
  }

  # Generate per-iteration seeds
  sim_seeds <- sample.int(.Machine$integer.max, iterations)

  data_mat <- as.matrix(data)
  sample_n <- nrow(data_mat)
  is_polytomous <- max(data_mat, na.rm = TRUE) > 1L

  item_names_vec <- colnames(data_mat)

  # Generating model: CML item thresholds (psychotools) + WLE person locations,
  # consistent with the rest of the package, replacing eRm CML +
  # eRm::person.parameter() (MLE). Thetas form the pool resampled with
  # replacement to build each simulated dataset; a dichotomous item is a
  # 2-category PCM, so its centred threshold is the item difficulty for rrm().
  pool <- .wle_theta_pool(data_mat)
  thr_list <- pool$thr_list
  thetas <- pool$thetas

  if (is_polytomous) {
    sim_data_list <- list(
      type = "polytomous",
      thetas = thetas,
      deltaslist = thr_list,
      n_items = ncol(data_mat),
      sample_n = sample_n,
      item_names = item_names_vec
    )
  } else {
    sim_data_list <- list(
      type = "dichotomous",
      thetas = thetas,
      item_params = unlist(thr_list, use.names = FALSE),
      n_items = ncol(data_mat),
      sample_n = sample_n,
      item_names = item_names_vec
    )
  }

  if (use_parallel) {
    results_raw <- run_partgam_LD_sim_parallel(
      iterations,
      sim_seeds,
      sim_data_list,
      n_cores,
      verbose
    )
  } else {
    results_raw <- run_partgam_LD_sim_sequential(
      iterations,
      sim_seeds,
      sim_data_list,
      verbose
    )
  }

  # Filter out failures (character strings indicate errors)
  ok <- vapply(results_raw, is.data.frame, logical(1L))
  successful <- results_raw[ok]

  if (length(successful) == 0L) {
    stop("All simulation iterations failed. Check your data.", call. = FALSE)
  }

  actual_iterations <- length(successful)

  # Combine per-iteration data.frames
  iter_dfs <- lapply(seq_along(successful), function(i) {
    df <- successful[[i]]
    df$iteration <- i
    df
  })
  results_df <- do.call(rbind, iter_dfs)
  rownames(results_df) <- NULL

  # Compute per-pair cutoffs
  pair_keys <- unique(paste(results_df$Item1, results_df$Item2, sep = "___"))
  pair_cutoffs <- do.call(
    rbind,
    lapply(pair_keys, function(pk) {
      parts <- strsplit(pk, "___", fixed = TRUE)[[1]]
      sub <- results_df[
        results_df$Item1 == parts[1] & results_df$Item2 == parts[2],
      ]
      if (cutoff_method == "hdci") {
        gamma_interval <- ggdist::hdci(sub$gamma, .width = hdci_width)
        data.frame(
          Item1 = parts[1],
          Item2 = parts[2],
          gamma_low = gamma_interval[1L, 1L],
          gamma_high = gamma_interval[1L, 2L],
          stringsAsFactors = FALSE,
          row.names = NULL
        )
      } else {
        data.frame(
          Item1 = parts[1],
          Item2 = parts[2],
          gamma_low = stats::quantile(sub$gamma, 0.025, na.rm = TRUE),
          gamma_high = stats::quantile(sub$gamma, 0.975, na.rm = TRUE),
          stringsAsFactors = FALSE,
          row.names = NULL
        )
      }
    })
  )
  rownames(pair_cutoffs) <- NULL

  list(
    results = results_df,
    pair_cutoffs = pair_cutoffs,
    actual_iterations = actual_iterations,
    requested_iterations = iterations,
    sample_n = sample_n,
    sample_n_total = n_total,
    sample_has_na = has_na,
    sample_summary = summary(thetas),
    item_names = item_names_vec,
    cutoff_method = cutoff_method,
    hdci_width = hdci_width
  )
}

# ---------------------------------------------------------------------------
# Internal: vectorised partial gamma
# ---------------------------------------------------------------------------

#' Partial gamma for every item pair, coefficient only
#'
#' Vectorised implementation of Davis's (1967) partial gamma. It returns the
#' coefficient and nothing else, which is all a parametric bootstrap needs.
#' `iarm::partgam_LD()` additionally computes the Goodman-Kruskal delta-method
#' variance and loops over pairs, cells and strata in R, costing roughly 300 ms
#' per call irrespective of sample size, so calling it once per bootstrap
#' iteration dominates everything else by an order of magnitude.
#'
#' Used for the simulated null in [RMlocdepGammaCutoff()] and for the observed
#' statistic that [RMlocdepGamma()] tests against it, so that the two cannot
#' diverge. The `gamma`, `se`, `lower` and `upper` columns shown to users still
#' come from `iarm::partgam_LD()`. Agreement with `iarm` is exact and is
#' asserted in `tests/testthat/test-ld_partgam_gamma.R`.
#'
#' @details
#' Partial gamma pools concordant and discordant pair counts over strata of the
#' conditioning variable, here the rest score:
#' \deqn{\gamma = \frac{\sum_k C_k - \sum_k D_k}{\sum_k C_k + \sum_k D_k}.}
#' For a stratum with an \eqn{m \times m} count matrix \eqn{N} and \eqn{G} the
#' strictly-upper indicator (\eqn{G_{ab} = 1} iff \eqn{b > a}), writing
#' \eqn{A = GN} gives \eqn{A_{ij'} = \sum_{i' > i} N_{i'j'}}, and then
#' \eqn{C = A G^{T}} sums over \eqn{j' > j} while \eqn{D = A G} sums over
#' \eqn{j' < j}. Each unordered observation pair is counted once, so the
#' halving `iarm` applies is not needed here.
#'
#' @param data data.frame or matrix of item responses scored from 0, with no
#'   missing values. `iarm::partgam_LD()` applies `complete.cases()` internally;
#'   this function does not, so the caller must filter first.
#' @param direction `1` enumerates pairs with `Item1` before `Item2` in column
#'   order, `2` the reverse. The rest score always excludes `Item2`, so the two
#'   directions give the two conditional independence hypotheses of Kreiner and
#'   Christensen (2004). [RMlocdepGammaCutoff()] stores the larger of the two.
#' @param strata When `TRUE`, adds the stratum sign-homogeneity columns
#'   described in `.partgam_strata_summary()`. Off by default, since the
#'   bootstrap needs the coefficient alone and calls this once per iteration.
#' @return data.frame with `Item1`, `Item2` and `gamma`, one row per pair, in
#'   the same order as `iarm::partgam_LD()[[direction]]`. `gamma` is `NA_real_`
#'   for a pair with no concordant and no discordant observations, which
#'   `iarm::partgam_LD()` cannot return at all because it errors on the whole
#'   data set when an item is constant. With `strata = TRUE` the columns
#'   `n_strata`, `n_pos`, `n_neg`, `n_zero`, `homogeneous` and `w_opposing` are
#'   appended, and the per-stratum detail behind them is attached as the
#'   `"strata"` attribute, a list with one element per pair.
#' @keywords internal
#' @noRd
.partgam_ld_gamma <- function(data, direction = 1L, strata = FALSE) {
  X <- as.matrix(data)
  storage.mode(X) <- "integer"

  if (ncol(X) < 2L) {
    stop("`data` must have at least two items.", call. = FALSE)
  }
  if (anyNA(X)) {
    stop(
      "`data` must not contain missing values; filter to complete cases first.",
      call. = FALSE
    )
  }

  items <- colnames(X)
  if (is.null(items)) {
    items <- paste0("V", seq_len(ncol(X)))
  }
  k <- ncol(X)
  m <- max(X) + 1L
  score <- rowSums(X)

  # G[a, b] = 1 iff b > a. Its transpose is the strictly-lower counterpart.
  G <- outer(seq_len(m), seq_len(m), function(a, b) as.numeric(b > a))
  tG <- t(G)

  # iarm enumerates with i in the outer loop and j in the inner one, sending
  # i < j to the first table and i > j to the second. Reproduced here so the
  # row order matches.
  pairs <- do.call(
    rbind,
    lapply(seq_len(k), function(i) {
      js <- if (direction == 1L) {
        seq_len(k)[seq_len(k) > i]
      } else {
        seq_len(k)[seq_len(k) < i]
      }
      if (length(js) == 0L) NULL else cbind(i = i, j = js)
    })
  )

  if (!strata) {
    gammas <- vapply(
      seq_len(nrow(pairs)),
      function(p) {
        i <- pairs[p, "i"]
        j <- pairs[p, "j"]
        .partgam_one(X[, i], X[, j], score - X[, j], m, G, tG)
      },
      numeric(1L)
    )
    return(data.frame(
      Item1 = items[pairs[, "i"]],
      Item2 = items[pairs[, "j"]],
      gamma = gammas,
      stringsAsFactors = FALSE,
      row.names = NULL
    ))
  }

  detail <- lapply(seq_len(nrow(pairs)), function(p) {
    i <- pairs[p, "i"]
    j <- pairs[p, "j"]
    .partgam_one(X[, i], X[, j], score - X[, j], m, G, tG, strata = TRUE)
  })

  out <- data.frame(
    Item1 = items[pairs[, "i"]],
    Item2 = items[pairs[, "j"]],
    gamma = vapply(detail, function(d) d$gamma, numeric(1L)),
    stringsAsFactors = FALSE,
    row.names = NULL
  )
  out <- cbind(out, do.call(rbind, lapply(detail, .partgam_strata_summary)))
  attr(out, "strata") <- detail
  out
}

#' Partial gamma for one item pair
#'
#' @param x,y Integer response vectors scored from 0.
#' @param z Integer conditioning variable (the rest score).
#' @param m Number of response categories spanning `x` and `y`.
#' @param G,tG The strictly-upper indicator matrix and its transpose.
#' @param strata When `FALSE` (default) the coefficient is returned on its own,
#'   which is the path the bootstrap takes. When `TRUE` the stratum-level
#'   quantities behind it are returned as well.
#' @return With `strata = FALSE`, the partial gamma coefficient, or `NA_real_`
#'   when no pair of observations within a stratum is either concordant or
#'   discordant. With `strata = TRUE`, a list with that value as `gamma` plus
#'   `gamma_k` (per-stratum gamma, `NA_real_` where a stratum yields no
#'   concordant or discordant pair), `weight_k` (that stratum's contribution to
#'   the denominator, \eqn{C_k + D_k}) and `n_k` (stratum size). Strata are in
#'   ascending order of `z`, including any empty intermediate ones.
#' @keywords internal
#' @noRd
.partgam_one <- function(x, y, z, m, G, tG, strata = FALSE) {
  zc <- z - min(z) + 1L
  nz <- max(zc)
  counts <- tabulate(
    (zc - 1L) * m * m + y * m + x + 1L,
    nbins = m * m * nz
  )
  dim(counts) <- c(m, m, nz)

  conc <- 0
  disc <- 0
  if (strata) {
    gamma_k <- rep(NA_real_, nz)
    weight_k <- rep(0, nz)
    n_k <- rep(0L, nz)
  }

  for (s in seq_len(nz)) {
    N <- counts[, , s]
    n_s <- sum(N)
    if (strata) n_k[s] <- n_s
    # A stratum holding fewer than two observations contributes no pairs.
    if (n_s < 2L) next
    A <- G %*% N
    c_s <- sum(N * (A %*% tG))
    d_s <- sum(N * (A %*% G))
    conc <- conc + c_s
    disc <- disc + d_s
    if (strata) {
      weight_k[s] <- c_s + d_s
      # A stratum can hold observations yet no comparable pair, for instance
      # when every respondent in it gave the same answer to one of the items.
      if (c_s + d_s > 0) gamma_k[s] <- (c_s - d_s) / (c_s + d_s)
    }
  }

  total <- conc + disc
  gamma <- if (total == 0) NA_real_ else (conc - disc) / total

  if (!strata) {
    return(gamma)
  }
  list(gamma = gamma, gamma_k = gamma_k, weight_k = weight_k, n_k = n_k)
}

#' Summarise sign homogeneity across strata for one item pair
#'
#' Davis's partial gamma pools concordant and discordant counts over strata, so
#' it is interpretable as a partial correlation only when the stratum-specific
#' associations point the same way. Kreiner (personal communication, 2026)
#' states the condition directly: the stratum gammas need not be equal, but they
#' must be either positive or negative throughout, and if some are negative
#' while others are zero or positive the pooled value is not a meaningful
#' measure of partial correlation.
#'
#' Two cautions on reading the result. Stratum gammas estimated from a handful
#' of respondents change sign readily by chance, so at small sample sizes a
#' heterogeneous verdict is weak evidence of a real violation. And under the
#' null the stratum gammas are zero in the population, so mixed sample signs are
#' expected there and carry no meaning.
#'
#' @param st A list from `.partgam_one(strata = TRUE)`.
#' @return A one-row data.frame with `n_strata` (strata contributing at least
#'   one comparable pair), `n_pos`, `n_neg`, `n_zero`, `homogeneous` (no
#'   stratum positive while another is negative) and `w_opposing`, the share of
#'   the pooled denominator held by strata whose sign opposes the pooled one.
#'   `w_opposing` is `NA_real_` when the pooled gamma is zero or undefined.
#' @keywords internal
#' @noRd
.partgam_strata_summary <- function(st) {
  ok <- !is.na(st$gamma_k)
  g <- st$gamma_k[ok]
  w <- st$weight_k[ok]

  n_pos <- sum(g > 0)
  n_neg <- sum(g < 0)

  pooled_sign <- if (is.na(st$gamma)) NA_real_ else sign(st$gamma)
  w_opposing <- if (is.na(pooled_sign) || pooled_sign == 0 || sum(w) == 0) {
    NA_real_
  } else {
    sum(w[sign(g) == -pooled_sign]) / sum(w)
  }

  data.frame(
    n_strata = length(g),
    n_pos = n_pos,
    n_neg = n_neg,
    n_zero = sum(g == 0),
    homogeneous = !(n_pos > 0 && n_neg > 0),
    w_opposing = w_opposing,
    stringsAsFactors = FALSE,
    row.names = NULL
  )
}

# ---------------------------------------------------------------------------
# Internal: single simulation iteration
# ---------------------------------------------------------------------------

#' Run a single partial gamma LD simulation iteration
#'
#' @param seed Integer seed for reproducibility.
#' @param data_list List produced inside [RMlocdepGammaCutoff()].
#' @return A data.frame with columns `Item1`, `Item2`, and `gamma`, or a
#'   character string on failure.
#' @keywords internal
run_single_partgam_LD_sim <- function(seed, data_list) {
  # The RNG kind is pinned, not just the seed: mirai daemons start under
  # L'Ecuyer-CMRG while the calling session uses the Mersenne-Twister
  # default, so seeding alone would make the parallel and sequential paths
  # draw different streams from the same `seed`.
  set.seed(
    seed,
    kind = "Mersenne-Twister",
    normal.kind = "Inversion",
    sample.kind = "Rejection"
  )

  thetas_res <- sample(
    data_list$thetas,
    size = data_list$sample_n,
    replace = TRUE
  )

  tryCatch(
    {
      if (data_list$type == "dichotomous") {
        sim_mat <- psychotools::rrm(
          theta = thetas_res,
          beta = data_list$item_params
        )
        sim_df <- as.data.frame(sim_mat$data)
        colnames(sim_df) <- data_list$item_names

        pos_counts <- colSums(sim_df, na.rm = TRUE)
        if (any(pos_counts < 8L)) {
          return(
            "validation_failed: fewer than 8 positive responses in at least one item"
          )
        }
      } else {
        sim_mat <- sim_partial_score(data_list$deltaslist, thetas_res)
        sim_df <- as.data.frame(sim_mat)
        colnames(sim_df) <- data_list$item_names

        n_cats <- vapply(
          data_list$deltaslist,
          function(d) length(d) + 1L,
          integer(1L)
        )
        for (j in seq_len(ncol(sim_df))) {
          tab <- tabulate(sim_df[[j]] + 1L, nbins = n_cats[j])
          if (any(tab == 0L)) {
            return("validation_failed: not all categories represented")
          }
        }
      }

      # The pair statistic is the larger of the two conditioning directions.
      # Local dependence violates both of the conditional independence
      # hypotheses that the Rasch model implies for a pair (Kreiner &
      # Christensen, 2004), so a pair is tested once, on whichever direction
      # shows the stronger association. Taking the maximum within the same
      # simulated dataset gives the null of that maximum directly, so nothing
      # has to be corrected for having looked at two directions.
      #
      # Computed with the vectorised internal rather than
      # `iarm::partgam_LD()`: the coefficients are identical (see
      # test-ld_partgam_gamma.R), but iarm costs around 300 ms per call against
      # a few milliseconds here, and it is called once per iteration.
      g1 <- .partgam_ld_gamma(sim_df, direction = 1L)
      g2 <- .partgam_ld_gamma(sim_df, direction = 2L)
      # direction 2 enumerates the same pairs with the items swapped, so it is
      # matched on the unordered pair rather than on row order.
      key <- function(a, b) paste(pmin(a, b), pmax(a, b), sep = "___")
      g1$gamma <- pmax(g1$gamma,
                       g2$gamma[match(key(g1$Item1, g1$Item2),
                                      key(g2$Item1, g2$Item2))])
      g1
    },
    error = function(e) {
      as.character(conditionMessage(e))
    }
  )
}

# ---------------------------------------------------------------------------
# Internal: parallel runner
# ---------------------------------------------------------------------------

#' Run partial gamma LD simulations in parallel using mirai
#'
#' @param iterations Number of iterations.
#' @param sim_seeds Integer vector of per-iteration seeds.
#' @param sim_data_list List of data passed to each worker.
#' @param n_cores Number of mirai daemons.
#' @param verbose Show progress bar.
#' @return List of raw results (one element per iteration).
#' @keywords internal
run_partgam_LD_sim_parallel <- function(
  iterations,
  sim_seeds,
  sim_data_list,
  n_cores,
  verbose = FALSE
) {
  mirai::daemons(n_cores)
  on.exit(mirai::daemons(0), add = TRUE)

  if (verbose) {
    message(sprintf("Starting %d daemons...", n_cores))
    pb <- utils::txtProgressBar(min = 0, max = iterations, style = 3)
    completed <- 0L
  }

  # Submit all tasks
  tasks <- lapply(seq_len(iterations), function(sim) {
    mirai::mirai(
      {
        run_single_partgam_LD_sim(seed, data_list)
      },
      seed = sim_seeds[sim],
      data_list = sim_data_list,
      run_single_partgam_LD_sim = run_single_partgam_LD_sim,
      sim_partial_score = sim_partial_score,
      sim_poly_item = sim_poly_item
    )
  })

  # Collect results
  results <- vector("list", iterations)
  for (sim in seq_len(iterations)) {
    result <- mirai::call_mirai(tasks[[sim]])$data
    if (!inherits(result, "errorValue")) {
      results[[sim]] <- result
    } else {
      results[[sim]] <- "mirai_error"
    }
    if (verbose) {
      completed <- completed + 1L
      utils::setTxtProgressBar(pb, completed)
    }
  }

  if (verbose) {
    close(pb)
    message("")
  }

  results
}

# ---------------------------------------------------------------------------
# Internal: sequential runner
# ---------------------------------------------------------------------------

#' Run partial gamma LD simulations sequentially
#'
#' @param iterations Number of iterations.
#' @param sim_seeds Integer vector of per-iteration seeds.
#' @param sim_data_list List of data passed to each worker.
#' @param verbose Show progress bar.
#' @return List of raw results (one element per iteration).
#' @keywords internal
run_partgam_LD_sim_sequential <- function(
  iterations,
  sim_seeds,
  sim_data_list,
  verbose = FALSE
) {
  if (verbose) {
    pb <- utils::txtProgressBar(min = 0, max = iterations, style = 3)
  }

  results <- vector("list", iterations)
  for (sim in seq_len(iterations)) {
    results[[sim]] <- run_single_partgam_LD_sim(sim_seeds[sim], sim_data_list)
    if (verbose) {
      utils::setTxtProgressBar(pb, sim)
    }
  }

  if (verbose) {
    close(pb)
    message("")
  }

  results
}

#' Plot Distribution of Simulated Partial Gamma LD Values
#'
#' Visualises the distribution of simulation-based partial gamma LD values
#' from \code{\link{RMlocdepGammaCutoff}}, optionally overlaying observed partial
#' gamma values computed from real data via \code{\link[iarm]{partgam_LD}}.
#'
#' Uses `ggdist::stat_dotsinterval()` (when `data` is not supplied) or
#' `ggdist::stat_dots()` (when `data` is supplied) with
#' `point_interval = "median_hdci"` and `.width = c(0.66, 0.95, 0.99)`.
#'
#' @param simfit The return value of \code{\link{RMlocdepGammaCutoff}} (a list with
#'   components `results`, `pair_cutoffs`, `actual_iterations`, `sample_n`, and
#'   `item_names`).
#' @param data Optional. A data.frame or matrix of item responses for computing
#'   and overlaying observed partial gamma values. Items must be scored starting
#'   at 0 (non-negative integers). When provided, the plot includes orange
#'   diamond markers for the observed partial gamma alongside the simulated
#'   distribution, plus segment summaries from the cutoff intervals.
#' @param items Optional character vector of item names to include in the plot.
#'   Only item pairs where **both** items are in this vector will be shown. When
#'   `NULL` (default), all item pairs are plotted.
#' @param n_pairs Optional positive integer. When supplied, only the
#'   `n_pairs` item pairs with the largest absolute partial gamma values
#'   are plotted, sorted by `|gamma|` descending. When `data` is
#'   supplied, the ranking uses the *observed* partial gammas (the
#'   diamonds you actually want to interpret); otherwise it falls back
#'   to the per-pair median of the simulated distributions. Applied
#'   *after* the `items` filter when both are supplied. Values larger
#'   than the number of available pairs are silently capped.
#'
#' @return A `ggplot` object.
#'
#' @details
#' The plot shows one row per item pair (labelled as "Item1 - Item2"). The
#' observed overlay is the larger of the pair's two rest-score directions,
#' matching the statistic `RMlocdepGamma()` tests and the simulated null.
#'
#' When `data` is **not** supplied, the function plots the simulated partial
#' gamma distributions as dot-interval plots using
#' `ggdist::stat_dotsinterval()` with median and Highest Density Continuous
#' Interval (HDCI) summaries.
#'
#' When `data` **is** supplied, the function:
#' \enumerate{
#'   \item Computes observed partial gamma values via
#'     `iarm::partgam_LD()`.
#'   \item Overlays observed gamma values as orange diamond markers on the
#'     simulated distributions.
#'   \item Shows per-pair cutoff intervals (from `simfit$pair_cutoffs`) as
#'     black line segments, with thicker segments for the 66\% interval and
#'     black dots for the median.
#' }
#'
#' The `ggplot2`, `ggdist`, and optionally `iarm` packages must be installed
#' (they are in Suggests, not Imports).
#'
#' @seealso \code{\link{RMlocdepGammaCutoff}}, \code{\link{RMlocdepGamma}}
#'
#' @importFrom rlang .data
#' @export
#'
#' @examples
#' \donttest{
#' if (requireNamespace("iarm", quietly = TRUE) &&
#'     requireNamespace("ggdist", quietly = TRUE) &&
#'     requireNamespace("ggplot2", quietly = TRUE)) {
#'   set.seed(42)
#'   sim_data <- as.data.frame(
#'     matrix(sample(0:1, 200 * 10, replace = TRUE), nrow = 200, ncol = 10)
#'   )
#'   colnames(sim_data) <- paste0("Item", 1:10)
#'
#'   # Run simulation
#'   cutoff_res <- RMlocdepGammaCutoff(sim_data, iterations = 100,
#'                                     parallel = FALSE, seed = 42)
#'
#'   # Simulated distribution only
#'   RMlocdepGammaPlot(cutoff_res)
#'
#'   # With observed partial gamma overlaid
#'   RMlocdepGammaPlot(cutoff_res, data = sim_data)
#'
#'   # Plot only a subset of items
#'   RMlocdepGammaPlot(cutoff_res, data = sim_data,
#'                     items = c("Item1", "Item2", "Item3"))
#' }
#' }
RMlocdepGammaPlot <- function(simfit, data, items = NULL, n_pairs = NULL) {
  # --- Check required packages ------------------------------------------------
  if (!requireNamespace("ggplot2", quietly = TRUE)) {
    stop(
      "Package 'ggplot2' is required for RMlocdepGammaPlot() but is not installed.\n",
      "Install it with: install.packages(\"ggplot2\")",
      call. = FALSE
    )
  }
  if (!requireNamespace("ggdist", quietly = TRUE)) {
    stop(
      "Package 'ggdist' is required for RMlocdepGammaPlot() but is not installed.\n",
      "Install it with: install.packages(\"ggdist\")",
      call. = FALSE
    )
  }

  # --- Validate simfit --------------------------------------------------------
  required_names <- c(
    "results",
    "pair_cutoffs",
    "actual_iterations",
    "sample_n",
    "item_names"
  )
  missing_names <- setdiff(required_names, names(simfit))
  if (length(missing_names) > 0L) {
    stop(
      "`simfit` is missing required components: ",
      paste(missing_names, collapse = ", "),
      ".\nExpected the return value of RMlocdepGammaCutoff().",
      call. = FALSE
    )
  }

  results_df <- simfit$results
  pair_cutoffs <- simfit$pair_cutoffs
  actual_iterations <- simfit$actual_iterations
  sample_n <- simfit$sample_n
  item_names <- simfit$item_names

  # Standard sample-size clause for the (complete-case) simulation sample.
  # `sample_n_total` / `sample_has_na` are absent in cutoff objects made by
  # older versions, so fall back to the plain count.
  sample_clause <- .n_caption(
    sample_n,
    if (is.null(simfit$sample_n_total)) sample_n else simfit$sample_n_total,
    if (isTRUE(simfit$sample_has_na)) "complete cases" else character()
  )

  # --- Validate items parameter -----------------------------------------------
  if (!is.null(items)) {
    unknown_items <- setdiff(items, item_names)
    if (length(unknown_items) > 0L) {
      stop(
        "Unknown item(s) in `items`: ",
        paste(unknown_items, collapse = ", "),
        ".\nAvailable items: ",
        paste(item_names, collapse = ", "),
        call. = FALSE
      )
    }
    if (length(items) < 2L) {
      stop(
        "`items` must contain at least 2 item names to form a pair.",
        call. = FALSE
      )
    }
  }

  # --- Validate n_pairs parameter ---------------------------------------------
  if (!is.null(n_pairs)) {
    if (
      !is.numeric(n_pairs) ||
        length(n_pairs) != 1L ||
        !is.finite(n_pairs) ||
        n_pairs < 1 ||
        n_pairs != as.integer(n_pairs)
    ) {
      stop(
        "`n_pairs` must be a single positive integer or NULL.",
        call. = FALSE
      )
    }
    n_pairs <- as.integer(n_pairs)
  }

  # Create pair labels
  results_df$Pair <- paste(results_df$Item1, "-", results_df$Item2)
  pair_cutoffs$Pair <- paste(pair_cutoffs$Item1, "-", pair_cutoffs$Item2)

  # --- Filter to selected items -----------------------------------------------
  if (!is.null(items)) {
    keep <- results_df$Item1 %in% items & results_df$Item2 %in% items
    results_df <- results_df[keep, , drop = FALSE]
    keep_cut <- pair_cutoffs$Item1 %in% items & pair_cutoffs$Item2 %in% items
    pair_cutoffs <- pair_cutoffs[keep_cut, , drop = FALSE]

    if (nrow(results_df) == 0L) {
      stop("No item pairs remain after filtering by `items`.", call. = FALSE)
    }
  }

  # --- Compute observed partial gammas up-front (when data supplied) ----------
  # We pre-compute here so the `n_pairs` filter below can rank by observed
  # |gamma| when data are available. The result is reused in Case 2.
  observed_df <- NULL
  if (!missing(data)) {
    if (!requireNamespace("iarm", quietly = TRUE)) {
      stop(
        "Package 'iarm' is required to compute observed partial gamma but is not installed.\n",
        "Install it with: install.packages(\"iarm\")",
        call. = FALSE
      )
    }
    validate_response_data(data)

    old_rgl <- getOption("rgl.useNULL")
    options(rgl.useNULL = TRUE)
    on.exit(options(rgl.useNULL = old_rgl), add = TRUE)

    # The simulated distribution is the null of the pair statistic, the larger
    # of the two conditioning directions, so the observed overlay has to be the
    # same quantity rather than one direction's coefficient.
    dc <- data[stats::complete.cases(data), , drop = FALSE]
    o1 <- .partgam_ld_gamma(dc, direction = 1L)
    o2 <- .partgam_ld_gamma(dc, direction = 2L)
    pkey <- function(a, b) paste(pmin(a, b), pmax(a, b), sep = "___")

    observed_df <- data.frame(
      Item1 = o1$Item1,
      Item2 = o1$Item2,
      observed_gamma = pmax(
        o1$gamma,
        o2$gamma[match(pkey(o1$Item1, o1$Item2), pkey(o2$Item1, o2$Item2))]
      ),
      stringsAsFactors = FALSE
    )
    observed_df$Pair <- paste(observed_df$Item1, "-", observed_df$Item2)

    if (!is.null(items)) {
      keep_obs <- observed_df$Item1 %in% items & observed_df$Item2 %in% items
      observed_df <- observed_df[keep_obs, , drop = FALSE]
    }
  }

  # --- Apply n_pairs top-N filter ---------------------------------------------
  if (!is.null(n_pairs)) {
    if (!is.null(observed_df)) {
      # Rank by |observed gamma| — the diamonds the user wants to interpret
      ord <- order(abs(observed_df$observed_gamma), decreasing = TRUE)
      keep_n <- min(n_pairs, nrow(observed_df))
      keep_pairs <- observed_df$Pair[ord[seq_len(keep_n)]]
      observed_df <- observed_df[
        observed_df$Pair %in% keep_pairs,
        ,
        drop = FALSE
      ]
    } else {
      # Rank by |median simulated gamma| per pair
      pair_names_all <- unique(results_df$Pair)
      med_g <- vapply(
        pair_names_all,
        function(pp) {
          stats::median(results_df$gamma[results_df$Pair == pp], na.rm = TRUE)
        },
        numeric(1L)
      )
      ord <- order(abs(med_g), decreasing = TRUE)
      keep_n <- min(n_pairs, length(pair_names_all))
      keep_pairs <- pair_names_all[ord[seq_len(keep_n)]]
    }
    results_df <- results_df[results_df$Pair %in% keep_pairs, , drop = FALSE]
    pair_cutoffs <- pair_cutoffs[
      pair_cutoffs$Pair %in% keep_pairs,
      ,
      drop = FALSE
    ]
    # Use the ranked order for y-axis: largest |gamma| at the top
    pair_levels <- rev(keep_pairs)
  } else {
    pair_levels <- rev(unique(results_df$Pair))
  }

  # --- Compute per-pair summary intervals for segment overlays ----------------
  pair_names <- unique(results_df$Pair)
  lo_hi <- do.call(
    rbind,
    lapply(pair_names, function(pair) {
      sub <- results_df[results_df$Pair == pair, ]
      data.frame(
        Pair = pair,
        min_gamma = stats::quantile(sub$gamma, 0.005, na.rm = TRUE),
        max_gamma = stats::quantile(sub$gamma, 0.995, na.rm = TRUE),
        p66lo_gamma = stats::quantile(sub$gamma, 0.167, na.rm = TRUE),
        p66hi_gamma = stats::quantile(sub$gamma, 0.833, na.rm = TRUE),
        median_gamma = stats::median(sub$gamma, na.rm = TRUE),
        stringsAsFactors = FALSE,
        row.names = NULL
      )
    })
  )
  rownames(lo_hi) <- NULL

  # --- Case 1: no observed data, show simulation distribution only ------------
  if (missing(data)) {
    results_plot <- data.frame(
      Pair = results_df$Pair,
      Value = results_df$gamma,
      stringsAsFactors = FALSE
    )
    results_plot$Pair <- factor(results_plot$Pair, levels = pair_levels)

    p <- ggplot2::ggplot(
      results_plot,
      ggplot2::aes(
        x = .data$Value,
        y = .data$Pair
      )
    ) +
      ggdist::stat_dotsinterval(
        ggplot2::aes(slab_fill = ggplot2::after_stat(.data$level)),
        quantiles = actual_iterations,
        point_interval = "median_hdci",
        layout = "weave",
        slab_color = NA,
        .width = c(0.66, 0.95, 0.99)
      ) +
      ggplot2::geom_vline(
        xintercept = 0,
        linetype = "dashed",
        color = "grey50",
        linewidth = 0.4
      ) +
      ggplot2::labs(
        x = "Partial gamma",
        y = "Item pair",
        caption = er2_caption(paste0(
          "Results from ",
          actual_iterations,
          " simulated datasets (no true local dependence). ",
          sample_clause,
          " per dataset."
        ))
      ) +
      ggplot2::scale_color_manual(
        values = scales::brewer_pal()(3),
        aesthetics = "slab_fill",
        guide = "none"
      ) +
      ggplot2::theme_minimal() +
      ggplot2::theme(panel.spacing = ggplot2::unit(0.7, "cm")) +
      er2_axis_margins() +
      er2_plot_caption()

    return(p)
  }

  # --- Case 2: observed data supplied -----------------------------------------
  # (`observed_df` was pre-computed above so the n_pairs filter could rank
  # by |observed gamma|.)

  # --- Build plot data --------------------------------------------------------
  gamma_sim <- data.frame(
    Pair = results_df$Pair,
    Value = results_df$gamma,
    stringsAsFactors = FALSE
  )
  gamma_sim <- merge(
    gamma_sim,
    observed_df[, c("Pair", "observed_gamma")],
    by = "Pair",
    sort = FALSE
  )
  gamma_sim$Pair <- factor(gamma_sim$Pair, levels = pair_levels)

  lo_hi$Pair_f <- factor(lo_hi$Pair, levels = pair_levels)

  caption_text <- er2_caption(paste0(
    "Results from ",
    actual_iterations,
    " simulated datasets. ",
    sample_clause,
    " per dataset.\n",
    "Orange diamonds indicate observed partial gamma LD. ",
    "Black dots indicate median gamma from simulations."
  ))

  p <- ggplot2::ggplot(
    gamma_sim,
    ggplot2::aes(
      x = .data$Value,
      y = .data$Pair
    )
  ) +
    ggdist::stat_dots(
      ggplot2::aes(slab_fill = ggplot2::after_stat(.data$level)),
      quantiles = actual_iterations,
      layout = "weave",
      slab_color = NA,
      .width = c(0.66, 0.95, 0.99)
    ) +
    ggplot2::geom_segment(
      data = lo_hi,
      ggplot2::aes(
        x = .data$min_gamma,
        xend = .data$max_gamma,
        y = .data$Pair_f,
        yend = .data$Pair_f
      ),
      color = "black",
      linewidth = 0.7
    ) +
    ggplot2::geom_segment(
      data = lo_hi,
      ggplot2::aes(
        x = .data$p66lo_gamma,
        xend = .data$p66hi_gamma,
        y = .data$Pair_f,
        yend = .data$Pair_f
      ),
      color = "black",
      linewidth = 1.2
    ) +
    ggplot2::geom_point(
      data = lo_hi,
      ggplot2::aes(
        x = .data$median_gamma,
        y = .data$Pair_f
      ),
      size = 3.6
    ) +
    ggplot2::geom_point(
      ggplot2::aes(x = .data$observed_gamma),
      color = "sienna2",
      shape = 18,
      position = ggplot2::position_nudge(y = -0.1),
      size = 4
    ) +
    ggplot2::labs(
      x = "Partial gamma",
      y = "Item pair",
      caption = caption_text
    ) +
    ggplot2::scale_color_manual(
      values = scales::brewer_pal()(3),
      aesthetics = "slab_fill",
      guide = "none"
    ) +
    ggplot2::theme_minimal() +
    ggplot2::theme(panel.spacing = ggplot2::unit(0.7, "cm")) +
    er2_axis_margins() +
    er2_plot_caption()

  p
}

Try the easyRasch2 package in your browser

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

easyRasch2 documentation built on Sept. 13, 2026, 1:07 a.m.