R/istem-helpers.R

Defines functions istem_prepare_fc_data istem_unpack_corr_rhat istem_unpack_corr istem_param_corr_vec istem_named_theta istem_prepare_length_poly istem_prepare_response istem_prepare_signed_q istem_prepare_01_q tirt_istem_loglik_trace fcggum_istem_loglik_trace fcmirt_istem_loglik_trace mggum_istem_loglik_trace mgpcm_istem_loglik_trace mirt_istem_loglik_trace istem_loglik_indexed_sum istem_loglik_binary_sum istem_loglik_grid istem_run istem_run_batches istem_trace_logLik_values istem_kernel istem_geweke_z istem_batch_var istem_update_corr_weighted istem_update_corr istem_corr_objective_weighted istem_corr_objective istem_corr_chol istem_safe_corr istem_attach_corr_cache istem_progress istem_check_method_control istem_method_control istem_effective_grid_model_control istem_apply_grid_control istem_latent_grid_control istem_default_L istem_state_weight istem_expand_group_matrix istem_response_pair_groups istem_response_groups istem_random_corr istem_rlnorm_bounded istem_rnorm_bounded istem_random_theta istem_clip istem_scalar fc_stan_method_control fit_effective_method_control fit_common_method_control resolve_block_items resolve_fc_type resolve_D model_type_to_int resolve_model_type fc_as_control_list

Documented in model_type_to_int resolve_block_items resolve_D resolve_fc_type resolve_model_type

################################# fit shared helpers #################################

fc_as_control_list <- function(control, arg) {
  if (is.null(control)) {
    return(list())
  }
  if (!is.list(control)) {
    stop("'", arg, "' must be NULL or a named list.", call. = FALSE)
  }
  if (length(control) > 0L) {
    control_names <- names(control)
    if (is.null(control_names) || any(!nzchar(control_names))) {
      stop("'", arg, "' must be a named list.", call. = FALSE)
    }
  }
  control
}


################################# model name aliases #################################

model_aliases <- list(
  "rasch" = "m1pl", "1pl" = "m1pl", "m1pl" = "m1pl",
  "2pl"  = "m2pl", "m2pl" = "m2pl",
  "3pl"  = "m3pl", "m3pl" = "m3pl",
  "4pl"  = "m4pl", "m4pl" = "m4pl"
)

#' Resolve IRT model type with alias support
#'
#' Accepts both internal codes (\code{"m1pl"}, \code{"m2pl"},
#' \code{"m3pl"}, \code{"m4pl"}) and user-friendly aliases
#' (\code{"Rasch"}, \code{"1PL"}, \code{"2PL"}, \code{"3PL"},
#' \code{"4PL"}).
#'
#' @param model Character scalar; the model identifier.
#' @return Lower-case internal model code.
#' @keywords internal
resolve_model_type <- function(model) {
  if (length(model) != 1L || is.na(model) || !nzchar(as.character(model))) {
    stop("'model' must be a non-missing character scalar.", call. = FALSE)
  }
  model <- tolower(as.character(model))
  resolved <- model_aliases[[model]]
  if (is.null(resolved)) {
    stop(
      "'model' must be one of: ",
      paste(sort(unique(unlist(model_aliases, use.names = FALSE))), collapse = ", "),
      " (or their aliases: Rasch, 1PL, 2PL, 3PL, 4PL).",
      call. = FALSE
    )
  }
  resolved
}

#' Map resolved model name to integer code
#'
#' Converts the canonical model string returned by \code{resolve_model_type()}
#' to the integer code expected by the Stan models (1 = 1PL, 2 = 2PL,
#' 3 = 3PL, 4 = 4PL).
#'
#' @param model Character string (canonical model name).
#' @return Integer model type code.
#' @keywords internal
model_type_to_int <- function(model) {
  switch(model,
    m1pl = 1L, m2pl = 2L, m3pl = 3L, m4pl = 4L,
    stop("Unknown model: ", model, call. = FALSE)
  )
}


################################# auto-detection helpers #################################

#' Resolve the number of latent dimensions
#'
#' Uses an explicit \eqn{D}, or infers it from the number of columns in a
#' supplied Q-matrix. Latent dimensionality is never guessed from the number
#' of items or blocks.
#'
#' @param D User-supplied D or NULL.
#' @param Q.matrix Optional Q-matrix.
#' @param block.items Deprecated internal argument; ignored.
#' @param I Deprecated internal argument; ignored.
#' @return Integer D.
#' @keywords internal
resolve_D <- function(D, Q.matrix = NULL, block.items = NULL, I = NULL) {
  if (!is.null(D)) {
    return(check_integer_scalar(D, "D"))
  }

  if (!is.null(Q.matrix)) {
    Q.matrix <- as.matrix(Q.matrix)
    if (ncol(Q.matrix) >= 1L) {
      return(ncol(Q.matrix))
    }
  }

  stop("'D' must be supplied when 'Q.matrix' is NULL.", call. = FALSE)
}


#' Auto-detect forced-choice response type from data
#'
#' When \code{fc.type} is \code{NULL}, inspects the ranking strings in the
#' data columns to infer whether they represent RANK, MOLE, or PICK.
#'
#' @param fc.type User-supplied fc.type or NULL.
#' @param data The forced-choice data matrix.
#' @param block.items Optional block-item list.
#' @return Character vector of fc.type values (one per block).
#' @keywords internal
resolve_fc_type <- function(fc.type, data, block.items = NULL) {
  data.matrix <- as.matrix(data)
  N.block <- ncol(data.matrix)
  if (!is.null(fc.type)) {
    return(normalize_fc_type(fc.type, N.block))
  }

  if (!is.character(data.matrix)) {
    return(rep("RANK", N.block))
  }

  if (!is.null(block.items) && length(block.items) != N.block) {
    stop("'block.items' must have one element per data column.",
         call. = FALSE)
  }

  inferred <- character(N.block)
  for (b in seq_len(N.block)) {
    x <- trimws(as.character(data.matrix[, b]))
    x <- x[!is.na(x) & nzchar(x)]
    if (length(x) == 0L) {
      stop("Block ", b, " contains no non-missing responses.",
           call. = FALSE)
    }
    widths <- lengths(strsplit(x, ">", fixed = TRUE))
    if (length(unique(widths)) != 1L) {
      stop("Block ", b, " mixes response formats; supply a consistent ",
           "'fc.type' and 'block.items'.", call. = FALSE)
    }
    width <- widths[1L]
    if (is.null(block.items)) {
      parsed <- lapply(strsplit(x, ">", fixed = TRUE), function(parts) {
        suppressWarnings(as.integer(trimws(parts)))
      })
      if (any(vapply(parsed, anyNA, logical(1L)))) {
        stop("Cannot parse item indices in block ", b, ".", call. = FALSE)
      }
      item_sets <- vapply(
        parsed, function(items) paste(sort(unique(items)), collapse = ","),
        character(1L)
      )
      if (length(unique(item_sets)) != 1L ||
          length(unique(parsed[[1L]])) != width) {
        stop("Partial rankings require explicit 'block.items' and ",
             "'fc.type'.", call. = FALSE)
      }
      inferred[b] <- "RANK"
    } else {
      K <- length(block.items[[b]])
      inferred[b] <- if (width == K) {
        "RANK"
      } else if (width == 2L && K > 2L) {
        "MOLE"
      } else if (width == 1L) {
        "PICK"
      } else {
        stop("Block ", b, " response width does not match 'block.items'.",
             call. = FALSE)
      }
    }
  }
  inferred
}


#' Auto-detect block.items from forced-choice ranking data
#'
#' Parses ranking strings like \code{"2>1>3"} to extract the set of item
#' indices in each block.  Only works when data columns contain character
#' ranking strings.
#'
#' @param block.items User-supplied block.items or NULL.
#' @param data The forced-choice data matrix.
#' @return A list of integer vectors (one per block), or NULL if
#'   auto-detection is not possible.
#' @keywords internal
resolve_block_items <- function(block.items, data) {
  data.matrix <- as.matrix(data)
  N.block <- ncol(data.matrix)
  if (!is.null(block.items)) {
    if (!is.list(block.items) || length(block.items) != N.block) {
      stop("'block.items' must be a list with one element per data column.",
           call. = FALSE)
    }
    block.items <- lapply(seq_along(block.items), function(b) {
      items <- block.items[[b]]
      if (!is.numeric(items) || anyNA(items) || any(!is.finite(items)) ||
          any(items != floor(items)) || any(items < 1) ||
          anyDuplicated(items)) {
        stop("'block.items[[", b, "]]' must contain unique positive ",
             "integer indices.", call. = FALSE)
      }
      as.integer(items)
    })
    if (anyDuplicated(unlist(block.items, use.names = FALSE))) {
      stop("Item indices must be unique across 'block.items'.",
           call. = FALSE)
    }
    return(block.items)
  }

  if (!is.character(data.matrix)) return(NULL)

  block.items <- vector("list", N.block)

  for (b in seq_len(N.block)) {
    x <- as.character(data.matrix[, b])
    x <- x[!is.na(x) & nzchar(x)]
    if (length(x) == 0L) {
      stop("Block ", b, " contains no valid data for auto-detection. ",
           "Please supply 'block.items' explicitly.", call. = FALSE)
    }
    parsed <- lapply(strsplit(x, ">", fixed = TRUE), function(parts) {
      suppressWarnings(as.integer(trimws(parts)))
    })
    if (any(vapply(parsed, anyNA, logical(1L)))) {
      stop("Cannot parse item indices from block ", b, " data. ",
           "Please supply 'block.items' explicitly.", call. = FALSE)
    }
    item.sets <- lapply(parsed, function(items) sort(unique(items)))
    keys <- vapply(
      item.sets, function(items) paste(items, collapse = ","), character(1L)
    )
    if (length(unique(keys)) != 1L ||
        any(lengths(item.sets) != lengths(parsed))) {
      stop("Block ", b, " contains partial or inconsistent rankings. ",
           "Please supply 'block.items' explicitly.", call. = FALSE)
    }
    block.items[[b]] <- item.sets[[1L]]
  }

  # Validate: items should form a contiguous set 1..I
  all_items <- sort(unique(unlist(block.items, use.names = FALSE)))
  I <- length(all_items)
  if (!all(all_items == seq_len(I))) {
    stop("Auto-detected block items are not a contiguous set 1..", I, ". ",
         "Please supply 'block.items' explicitly.", call. = FALSE)
  }

  message("block.items auto-detected: ", N.block, " blocks, ",
          I, " unique items")
  block.items
}

fit_common_method_control <- function(control.method) {
  control.method <- fc_as_control_list(control.method, "control.method")

  chains <- as.integer(get_ctrl("chains", 2L, control.method))
  cores <- get_ctrl("cores", chains, control.method)
  cores <- as.integer(cores)
  if (length(cores) != 1L || is.na(cores) || cores < 1L) {
    stop("'cores' in 'control.method' must be a positive integer.",
         call. = FALSE)
  }

  vis <- get_ctrl("vis", TRUE, control.method)
  if (!is.logical(vis) || length(vis) != 1L || is.na(vis)) {
    stop("'vis' in 'control.method' must be TRUE or FALSE.", call. = FALSE)
  }

  seed <- get_ctrl(
    "seed", sample.int(.Machine$integer.max, 1L), control.method
  )
  if ((!is.numeric(seed) && !is.integer(seed)) ||
      length(seed) != 1L || !is.finite(seed)) {
    stop("'seed' in 'control.method' must be a finite numeric scalar.",
         call. = FALSE)
  }
  seed <- as.integer(seed)
  if (is.na(seed)) {
    stop("'seed' in 'control.method' must be a valid integer.", call. = FALSE)
  }

  list(cores = cores, vis = vis, seed = seed)
}

fit_effective_method_control <- function(control.method, method = list(),
                                         common = list()) {
  control.method <- fc_as_control_list(control.method, "control.method")
  defaults <- utils::modifyList(common, method)
  utils::modifyList(defaults, control.method)
}

fc_stan_method_control <- function(control.method) {
  control.method <- fc_as_control_list(control.method, "control.method")

  iter <- as.integer(get_ctrl("iter", 5000L, control.method))
  stan.method <- list(
    chains = as.integer(get_ctrl("chains", 2L, control.method)),
    iter = iter,
    warmup = as.integer(get_ctrl("warmup", floor(iter / 2), control.method)),
    thin = as.integer(get_ctrl("thin", 1L, control.method)),
    init = get_ctrl("init", "random", control.method),
    algorithm = get_ctrl("algorithm", "HMC", control.method)
  )

  stan.method$algorithm <- match.arg(
    stan.method$algorithm,
    c("NUTS", "HMC", "Fixed_param")
  )

  integer_names <- c("chains", "iter", "warmup", "thin")
  for (nm in integer_names) {
    if (is.na(stan.method[[nm]]) || stan.method[[nm]] < 1L) {
      stop("'", nm, "' in 'control.method' must be a positive integer.",
           call. = FALSE)
    }
  }
  if (stan.method$warmup >= stan.method$iter) {
    stop("'warmup' in 'control.method' must be smaller than 'iter'.",
         call. = FALSE)
  }

  stan.method
}

istem_scalar <- function(value, name, positive = FALSE, arg = "control.model") {
  if (!is.numeric(value) || length(value) != 1L || !is.finite(value)) {
    stop("'", name, "' in '", arg, "' must be a finite numeric scalar.",
         call. = FALSE)
  }
  value <- as.numeric(value)
  if (positive && value <= 0) {
    stop("'", name, "' in '", arg, "' must be positive.", call. = FALSE)
  }
  value
}

istem_clip <- function(x, lower, upper) {
  pmin(pmax(x, lower), upper)
}

istem_random_theta <- function(N, D, theta.mu, lower, upper) {
  theta.mu <- rep(as.numeric(theta.mu), length.out = D)
  theta <- matrix(stats::rnorm(N * D, mean = rep(theta.mu, each = N), sd = 1),
                  nrow = N, ncol = D)
  istem_clip(theta, lower, upper)
}

istem_rnorm_bounded <- function(n, mean, sd, lower, upper) {
  istem_clip(stats::rnorm(n, mean = mean, sd = sd), lower, upper)
}

istem_rlnorm_bounded <- function(n, meanlog, sdlog, lower, upper) {
  istem_clip(stats::rlnorm(n, meanlog = meanlog, sdlog = sdlog), lower, upper)
}

istem_random_corr <- function(D, include.corr = TRUE) {
  if (D <= 1L || !isTRUE(include.corr)) {
    return(diag(D))
  }
  z <- matrix(stats::rnorm(D * (D + 1L)), nrow = D)
  corr <- stats::cov2cor(tcrossprod(z))
  diag(corr) <- 1
  corr
}

istem_response_groups <- function(response) {
  response <- as.matrix(response)
  key <- apply(response, 1L, paste, collapse = "\r")
  level <- unique(key)
  group <- match(key, level)
  first <- match(level, key)
  unique.response <- response[first, , drop = FALSE]
  storage.mode(unique.response) <- storage.mode(response)
  list(
    response = unique.response,
    count = as.integer(tabulate(group, nbins = length(level))),
    group = as.integer(group),
    first = as.integer(first),
    G = length(level)
  )
}

istem_response_pair_groups <- function(response, pairs.value) {
  response <- as.matrix(response)
  response.key <- apply(response, 1L, paste, collapse = "\r")
  pair.key <- vapply(pairs.value, function(person) {
    paste(vapply(person, function(block) {
      block <- as.matrix(block)
      paste(nrow(block), ncol(block), paste(as.integer(block), collapse = ","),
            sep = ":")
    }, character(1L)), collapse = "\r")
  }, character(1L))
  key <- paste(response.key, pair.key, sep = "\f")
  level <- unique(key)
  group <- match(key, level)
  first <- match(level, key)
  unique.response <- response[first, , drop = FALSE]
  storage.mode(unique.response) <- storage.mode(response)
  list(
    response = unique.response,
    pairs.value = pairs.value[first],
    count = as.integer(tabulate(group, nbins = length(level))),
    group = as.integer(group),
    first = as.integer(first),
    G = length(level)
  )
}

istem_expand_group_matrix <- function(x, group, row_names = NULL) {
  out <- x[group, , drop = FALSE]
  if (!is.null(row_names)) rownames(out) <- row_names
  out
}

istem_state_weight <- function(state) {
  if (!is.null(state$response.count)) state$response.count else rep(1, nrow(state$theta))
}

istem_default_L <- function(D) {
  switch(as.character(D),
         "1" = 61L, "2" = 31L, "3" = 15L, "4" = 9L, "5" = 7L, 3L)
}

istem_latent_grid_control <- function(control.model, D, L = NULL,
                                      theta.low = NULL, theta.up = NULL) {
  control.model <- fc_as_control_list(control.model, "control.model")
  D <- as.integer(D)
  if (length(D) != 1L || is.na(D) || D < 1L) {
    stop("'D' must be a positive integer.", call. = FALSE)
  }

  if (is.null(L)) {
    L <- get_ctrl("L", NULL, control.model)
  }
  if (is.null(L)) {
    L <- istem_default_L(D)
  }
  L <- as.integer(L)
  if (length(L) != 1L || is.na(L) || !is.finite(L) || L < 2L) {
    stop("'L' in 'control.model' must be an integer of at least 2.",
         call. = FALSE)
  }

  if (is.null(theta.low)) {
    theta.low <- get_ctrl("theta.lower", -6, control.model)
  }
  if (is.null(theta.up)) {
    theta.up <- get_ctrl("theta.upper", 6, control.model)
  }
  theta.low <- as.numeric(theta.low)
  theta.up <- as.numeric(theta.up)
  if (length(theta.low) != 1L || length(theta.up) != 1L ||
      !is.finite(theta.low) || !is.finite(theta.up) ||
      theta.low >= theta.up) {
    stop("'theta.lower' must be smaller than 'theta.upper' in 'control.model'.",
         call. = FALSE)
  }

  list(L = L, theta.lower = theta.low, theta.upper = theta.up)
}

istem_apply_grid_control <- function(method, control.model, D) {
  grid <- istem_latent_grid_control(control.model, D)
  method$L <- grid$L
  method$theta.lower <- grid$theta.lower
  method$theta.upper <- grid$theta.upper
  method
}

istem_effective_grid_model_control <- function(control.model, method) {
  utils::modifyList(
    control.model,
    list(L = method$L,
         theta.lower = method$theta.lower,
         theta.upper = method$theta.upper)
  )
}

istem_method_control <- function(control.method) {
  control.method <- fc_as_control_list(control.method, "control.method")
  list(
    M = as.integer(get_ctrl("M", 10L, control.method)),
    B = as.integer(get_ctrl("B", 20L, control.method)),
    burnin.maxitr = as.integer(get_ctrl("burnin.maxitr", 100L, control.method)),
    maxitr = as.integer(get_ctrl("maxitr", 2000L, control.method)),
    eps1 = get_ctrl("eps1", 1.5, control.method),
    eps2 = get_ctrl("eps2", 0.4, control.method),
    frac1 = get_ctrl("frac1", 0.1, control.method),
    frac2 = get_ctrl("frac2", 0.5, control.method),
    optim.maxit = as.integer(get_ctrl("optim.maxit", 200L, control.method)),
    corr.optim.maxit = as.integer(get_ctrl("corr.optim.maxit", 50L,
                                           control.method)),
    fix.corr = isTRUE(get_ctrl("fix.corr", FALSE, control.method)),
    estimate.se = isTRUE(get_ctrl("estimate.se", TRUE, control.method))
  )
}

istem_check_method_control <- function(method) {
  if (method$M < 2L || method$B < 1L) {
    stop("'M' must be at least 2 and 'B' must be at least 1 for iStEM.",
         call. = FALSE)
  }
  if (method$burnin.maxitr < method$M) {
    stop("'burnin.maxitr' must be at least as large as 'M'.", call. = FALSE)
  }
  if (method$maxitr < method$M) {
    stop("'maxitr' must be at least as large as 'M'.", call. = FALSE)
  }
  if (!is.null(method$L) &&
      (!is.finite(method$L) || as.integer(method$L) < 2L)) {
    stop("'L' in 'control.model' must be an integer of at least 2.",
         call. = FALSE)
  }
  if (!is.null(method$theta.lower) || !is.null(method$theta.upper)) {
    if (!is.finite(method$theta.lower) || !is.finite(method$theta.upper) ||
        method$theta.lower >= method$theta.upper) {
      stop("'theta.lower' must be smaller than 'theta.upper' in 'control.model'.",
           call. = FALSE)
    }
  }
  if (!is.finite(method$corr.optim.maxit) || method$corr.optim.maxit < 1L) {
    stop("'corr.optim.maxit' must be a positive integer.", call. = FALSE)
  }
  invisible(NULL)
}

istem_progress <- function(fmt, ..., width = 120L, newline = FALSE) {
  msg <- sprintf(fmt, ...)
  pad <- strrep(" ", max(0L, as.integer(width) - nchar(msg, type = "width")))
  cat("\r", msg, pad, if (newline) "\n" else "", sep = "")
  flush.console()
  invisible(NULL)
}

istem_attach_corr_cache <- function(C) {
  C <- as.matrix(C)
  chol_sigma <- tryCatch(chol(C), error = function(e) NULL)
  if (is.null(chol_sigma)) {
    return(C)
  }
  attr(C, "chol") <- chol_sigma
  attr(C, "log_diag_sum") <- sum(log(diag(chol_sigma)))
  C
}

istem_safe_corr <- function(x) {
  if (is.matrix(x) && nrow(x) == ncol(x)) {
    C <- x
  } else {
    C <- stats::cor(x)
  }
  C[!is.finite(C)] <- 0
  C <- (C + t(C)) / 2
  diag(C) <- 1
  eig <- tryCatch(eigen(C, symmetric = TRUE), error = function(e) NULL)
  if (is.null(eig)) {
    C <- diag(ncol(C))
    return(istem_attach_corr_cache(C))
  }
  eig$values <- pmax(eig$values, 1e-6)
  C <- eig$vectors %*% diag(eig$values, nrow = length(eig$values)) %*%
    t(eig$vectors)
  C <- stats::cov2cor(C)
  C[!is.finite(C)] <- 0
  C <- (C + t(C)) / 2
  diag(C) <- 1
  istem_attach_corr_cache(C)
}

istem_corr_chol <- function(Corr) {
  chol_sigma <- attr(Corr, "chol", exact = TRUE)
  log_diag_sum <- attr(Corr, "log_diag_sum", exact = TRUE)
  if (is.null(chol_sigma) || is.null(log_diag_sum)) {
    Corr <- istem_safe_corr(Corr)
    chol_sigma <- attr(Corr, "chol", exact = TRUE)
    log_diag_sum <- attr(Corr, "log_diag_sum", exact = TRUE)
  }
  list(chol = chol_sigma, log_diag_sum = log_diag_sum)
}

istem_corr_objective <- function(v, theta, theta.mu) {
  D <- ncol(theta)
  C <- diag(D)
  C[lower.tri(C)] <- v
  C <- C + t(C)
  diag(C) <- 1

  chol_sigma <- tryCatch(chol(C), error = function(e) NULL)
  if (is.null(chol_sigma)) {
    return(1e100)
  }

  z <- tryCatch(
    forwardsolve(t(chol_sigma), t(sweep(theta, 2L, theta.mu, "-"))),
    error = function(e) NULL
  )
  if (is.null(z) || any(!is.finite(z))) {
    return(1e100)
  }

  nrow(theta) * 2 * sum(log(diag(chol_sigma))) + sum(z^2)
}

istem_corr_objective_weighted <- function(v, theta, theta.mu, weight) {
  D <- ncol(theta)
  C <- diag(D)
  C[lower.tri(C)] <- v
  C <- C + t(C)
  diag(C) <- 1

  chol_sigma <- tryCatch(chol(C), error = function(e) NULL)
  if (is.null(chol_sigma)) {
    return(1e100)
  }

  z <- tryCatch(
    forwardsolve(t(chol_sigma), t(sweep(theta, 2L, theta.mu, "-"))),
    error = function(e) NULL
  )
  if (is.null(z) || any(!is.finite(z))) {
    return(1e100)
  }

  sum(weight) * 2 * sum(log(diag(chol_sigma))) +
    sum(sweep(z^2, 2L, weight, "*"))
}

istem_update_corr <- function(theta, theta.mu, Corr, maxit) {
  D <- ncol(theta)
  if (D <= 1L) {
    return(matrix(1, 1L, 1L))
  }

  Corr <- istem_safe_corr(Corr)
  x0 <- Corr[lower.tri(Corr)]
  opt <- tryCatch(
    stats::optim(
      par = x0,
      fn = istem_corr_objective,
      theta = theta,
      theta.mu = theta.mu,
      method = "L-BFGS-B",
      lower = rep(-0.995, length(x0)),
      upper = rep(0.995, length(x0)),
      control = list(maxit = maxit)
    ),
    error = function(e) NULL
  )

  if (!is.null(opt) && is.finite(opt$value)) {
    C <- diag(D)
    C[lower.tri(C)] <- opt$par
    C <- C + t(C)
    diag(C) <- 1
    return(istem_safe_corr(C))
  }

  Corr
}

istem_update_corr_weighted <- function(theta, theta.mu, weight, Corr, maxit) {
  D <- ncol(theta)
  if (D <= 1L) {
    return(matrix(1, 1L, 1L))
  }

  Corr <- istem_safe_corr(Corr)
  x0 <- Corr[lower.tri(Corr)]
  opt <- tryCatch(
    stats::optim(
      par = x0,
      fn = istem_corr_objective_weighted,
      theta = theta,
      theta.mu = theta.mu,
      weight = weight,
      method = "L-BFGS-B",
      lower = rep(-0.995, length(x0)),
      upper = rep(0.995, length(x0)),
      control = list(maxit = maxit)
    ),
    error = function(e) NULL
  )

  if (!is.null(opt) && is.finite(opt$value)) {
    C <- diag(D)
    C[lower.tri(C)] <- opt$par
    C <- C + t(C)
    diag(C) <- 1
    return(istem_safe_corr(C))
  }

  Corr
}

istem_batch_var <- function(plist, n) {
  out <- cpp_istem_batch_var(plist, as.integer(n))
  out[!is.finite(out)] <- Inf
  out
}

istem_geweke_z <- function(chain, frac1 = 0.2, frac2 = 0.5) {
  if (is.null(dim(chain))) {
    chain <- matrix(chain, nrow = 1L)
  }
  z <- coda::geweke.diag(
    coda::mcmc(t(chain)),
    frac1 = frac1,
    frac2 = frac2
  )$z
  z[!is.finite(z)] <- 0
  z
}

istem_kernel <- function(state, B, theta_grid_length, theta_lower, theta_upper,
                         fix.corr, corr_optim_maxit, update_parameters, param_vec,
                         sample_theta, logLik_fun = NULL) {
  state$Corr <- istem_attach_corr_cache(state$Corr)
  N <- nrow(state$theta)
  D <- ncol(state$theta)
  npar <- length(param_vec(state))
  pmatrix <- matrix(NA_real_, npar, B)
  logLik.trace <- rep(NA_real_, B)
  theta_sum <- matrix(0, N, D)
  theta_sq_sum <- matrix(0, N, D)

  for (b in seq_len(B)) {
    sampled <- sample_theta(
      state = state,
      theta_grid_length = theta_grid_length,
      theta_lower = theta_lower,
      theta_upper = theta_upper
    )
    state$theta <- sampled$theta

    if (!fix.corr && D > 1L) {
      weight <- istem_state_weight(state)
      state$Corr <- if (is.null(state$response.count)) {
        istem_update_corr(
          theta = state$theta,
          theta.mu = state$theta_mu,
          Corr = state$Corr,
          maxit = corr_optim_maxit
        )
      } else {
        istem_update_corr_weighted(
          theta = state$theta,
          theta.mu = state$theta_mu,
          weight = weight,
          Corr = state$Corr,
          maxit = corr_optim_maxit
        )
      }
    }

    state <- update_parameters(state)
    pmatrix[, b] <- param_vec(state)
    if (!is.null(logLik_fun)) {
      logLik.trace[b] <- logLik_fun(state)
    }
    theta_sum <- theta_sum + state$theta
    theta_sq_sum <- theta_sq_sum + state$theta^2
  }

  list(
    pmatrix = pmatrix,
    state = state,
    logLik.trace = logLik.trace,
    theta_sum = theta_sum,
    theta_sq_sum = theta_sq_sum
  )
}

istem_trace_logLik_values <- function(x) {
  value <- x$logLik.trace %||% x$logLik %||% x$log_lik %||% x$loglik
  if (is.null(value)) return(NA_real_)
  if (is.data.frame(value)) {
    value <- value$logLik %||% value$log_lik %||% value$loglik
  }
  value <- as.numeric(value)
  if (length(value) == 0L) NA_real_ else value
}

istem_run_batches <- function(state, method, N, vis, label, run_batch) {
  M <- method$M
  B <- method$B
  burnin.maxitr <- method$burnin.maxitr
  maxitr <- method$maxitr
  eps1 <- method$eps1
  eps2 <- method$eps2
  frac1 <- method$frac1
  frac2 <- method$frac2

  plist <- vector("list", M)
  trace.logLik <- numeric(0L)
  batch.extra.names <- NULL
  batch.extra <- list()

  if (vis) {
    cat("\n", label, " iStEM burn-in phase:\n", sep = "")
  }

  for (m in seq_len(M)) {
    if (vis) {
      istem_progress("  burn-in batch %04d/%04d", m, M)
    }
    x <- run_batch(state)
    state <- x$state
    plist[[m]] <- x$pmatrix
    trace.logLik <- c(trace.logLik, istem_trace_logLik_values(x))
    if (is.null(batch.extra.names)) {
      batch.extra.names <- setdiff(names(x), c("state", "pmatrix", "logLik",
                                               "log_lik", "loglik",
                                               "logLik.trace"))
      batch.extra <- stats::setNames(vector("list", length(batch.extra.names)),
                                     batch.extra.names)
      for (nm in batch.extra.names) batch.extra[[nm]] <- vector("list", M)
    }
    for (nm in batch.extra.names) batch.extra[[nm]][[m]] <- x[[nm]]
  }

  z <- istem_geweke_z(Reduce(cbind, plist), frac1 = frac1, frac2 = frac2)
  d.hat <- istem_batch_var(plist, n = M)

  burn.in.size <- 0L
  total.number.of.batch <- M
  m <- M
  while (sum(z^2) >= length(z) * eps1 &&
         m < burnin.maxitr) {

    x <- run_batch(state)
    state <- x$state
    trace.logLik <- c(trace.logLik, istem_trace_logLik_values(x))
    plist <- c(plist[-1L], list(x$pmatrix))
    for (nm in batch.extra.names) {
      batch.extra[[nm]] <- c(batch.extra[[nm]][-1L], list(x[[nm]]))
    }

    z <- istem_geweke_z(Reduce(cbind, plist), frac1 = frac1, frac2 = frac2)
    d.hat <- istem_batch_var(plist, n = M)
    burn.in.size <- burn.in.size + B
    total.number.of.batch <- total.number.of.batch + 1L
    m <- m + 1L

    if (vis) {
      istem_progress(
        "  burn-in batch %04d | Geweke mean z^2 %10.4f <= %-10.4f",
        m,
        sum(z^2) / length(z),
        eps1
      )
    }
  }

  burnin.z2.per.par <- sum(z^2) / length(z)
  burnin.max.Nu <- max(d.hat * N, na.rm = TRUE)
  burnin.converged <- burnin.z2.per.par < eps1
  if (!burnin.converged && m >= burnin.maxitr) {
    warning(
      label,
      " iStEM burn-in stopped at 'burnin.maxitr' before the Geweke criterion was met: ",
      "Geweke mean z^2 = ", signif(burnin.z2.per.par, 5),
      ", criterion = ", signif(eps1, 5), ".",
      call. = FALSE
    )
  }

  stable.plist <- plist
  stable.extra <- batch.extra
  n <- M
  d.hat <- istem_batch_var(stable.plist, n = n)

  if (vis) {
    cat("\nAfter burn-in phase:\n")
  }

  while (max(d.hat * N, na.rm = TRUE) >= eps2 && n < maxitr) {
    if (vis) {
      istem_progress(
        "  valid batch %04d | max N*u %10.4f <= %-10.4f",
        n,
        max(d.hat * N, na.rm = TRUE),
        eps2
      )
    }

    x <- run_batch(state)
    state <- x$state
    trace.logLik <- c(trace.logLik, istem_trace_logLik_values(x))
    stable.plist[[n + 1L]] <- x$pmatrix
    for (nm in batch.extra.names) stable.extra[[nm]][[n + 1L]] <- x[[nm]]
    n <- n + 1L
    total.number.of.batch <- total.number.of.batch + 1L
    d.hat <- istem_batch_var(stable.plist, n = n)
  }

  if (vis) {
    istem_progress(
      "  valid batch %04d | max N*u %10.4f <= %-10.4f",
      n,
      max(d.hat * N, na.rm = TRUE),
      eps2,
      newline = TRUE
    )
    cat("Length of final MC chain = ", n * B, "\n", sep = "")
  }

  final.max.Nu <- max(d.hat * N, na.rm = TRUE)
  final.converged <- final.max.Nu < eps2
  if (!final.converged && n >= maxitr) {
    warning(
      label,
      " iStEM stopped at 'maxitr' before the Monte Carlo error criterion was met: ",
      "max N*u = ", signif(final.max.Nu, 5),
      ", criterion = ", signif(eps2, 5), ".",
      call. = FALSE
    )
  }

  chain.length <- n * B
  stable.start <- max(1L, length(trace.logLik) - M * B + 1L)
  stable.start.batch <- ceiling(stable.start / B)
  logLik.trace <- data.frame(
    iter = seq_along(trace.logLik),
    logLik = trace.logLik
  )
  list(
    state = state,
    stable.plist = stable.plist,
    stable.extra = stable.extra,
    stable.start = stable.start,
    stable.start.batch = stable.start.batch,
    chain.length = chain.length,
    n.batch = n,
    iStEM = list(
      M = M,
      B = B,
      burn.in.size = burn.in.size,
      total.number.of.batch = total.number.of.batch,
      final.chain = chain.length,
      burnin.converged = burnin.converged,
      converged = final.converged,
      burnin.z2.per.par = burnin.z2.per.par,
      burnin.max.Nu = burnin.max.Nu,
      final.max.Nu = final.max.Nu,
      geweke.z = z,
      batch.var = d.hat,
      stable.start = stable.start,
      stable.start.batch = stable.start.batch,
      logLik.trace = logLik.trace,
      itemlist = stable.plist
    )
  )
}

istem_run <- function(state, method, N, vis, label,
                      update_parameters, param_vec,
                      sample_theta, logLik_fun = NULL) {
  L <- method$L
  theta_lower <- method$theta.lower
  theta_upper <- method$theta.upper
  corr_optim_maxit <- method$corr.optim.maxit
  fix.corr <- method$fix.corr
  estimate.se <- method$estimate.se

  run <- istem_run_batches(
    state = state,
    method = method,
    N = N,
    vis = vis,
    label = label,
    run_batch = function(state) {
      x <- istem_kernel(
        state = state,
        B = method$B,
        theta_grid_length = L,
        theta_lower = theta_lower,
        theta_upper = theta_upper,
        fix.corr = fix.corr,
        corr_optim_maxit = corr_optim_maxit,
        update_parameters = update_parameters,
        param_vec = param_vec,
        sample_theta = sample_theta,
        logLik_fun = logLik_fun
      )
      x
    }
  )

  chain_mat <- Reduce(cbind, run$stable.plist)
  chain_mean <- rowMeans(chain_mat)
  chain_sd <- if (estimate.se && ncol(chain_mat) > 1L) {
    apply(chain_mat, 1L, stats::sd)
  } else {
    rep(NA_real_, length(chain_mean))
  }
  chain_rhat <- rep(NA_real_, length(chain_mean))  # iStEM is not MCMC; Rhat not applicable

  theta_sum <- Reduce("+", run$stable.extra$theta_sum)
  theta_sq_sum <- Reduce("+", run$stable.extra$theta_sq_sum)
  chain_length <- run$chain.length
  theta_est <- theta_sum / chain_length
  theta_second <- theta_sq_sum / chain_length
  theta_se <- sqrt(pmax(theta_second - theta_est^2, 0))

  list(
    state = run$state,
    chain.mean = chain_mean,
    chain.sd = chain_sd,
    chain.rhat = chain_rhat,
    chain.mat = chain_mat,
    theta.est = theta_est,
    theta.se = theta_se,
    iStEM = utils::modifyList(
      run$iStEM,
      list(
      accept.rate = NA_real_,
        L = L
      )
    )
  )
}

istem_loglik_grid <- function(state, L, theta.lower, theta.upper) {
  D <- ncol(state$theta)
  make_quadrature_grid_mvn(
    state$Corr, state$theta_mu, D, L, theta.lower, theta.upper
  )
}

istem_loglik_binary_sum <- function(prob, response, count, pi) {
  response <- as.matrix(response)
  lik <- cpp_loglik_binary(
    prob = prob,
    response = matrix(as.integer(response), nrow = nrow(response),
                      ncol = ncol(response)),
    pi = as.vector(pi)
  )
  loglik_apply_group_count(lik, count)$logLik
}

istem_loglik_indexed_sum <- function(prob, response, count, pi, block_sizes,
                                     response_base) {
  response <- as.matrix(response)
  lik <- cpp_loglik_indexed(
    prob = prob,
    response = matrix(as.integer(response), nrow = nrow(response),
                      ncol = ncol(response)),
    pi = as.vector(pi),
    block_sizes = as.integer(block_sizes),
    response_base = as.integer(response_base)
  )
  loglik_apply_group_count(lik, count)$logLik
}

mirt_istem_loglik_trace <- function(state, L, theta.lower, theta.upper) {
  grid <- istem_loglik_grid(state, L, theta.lower, theta.upper)
  prob <- model.MIRT(grid$theta.norm, state$par)
  istem_loglik_binary_sum(prob, state$response, state$response.count, grid$pi)
}

mgpcm_istem_loglik_trace <- function(state, L, theta.lower, theta.upper) {
  grid <- istem_loglik_grid(state, L, theta.lower, theta.upper)
  prob <- model.MGPCM(grid$theta.norm, state$par)
  istem_loglik_indexed_sum(
    prob, state$response, state$response.count, grid$pi,
    block_sizes = state$length.poly, response_base = 0L
  )
}

mggum_istem_loglik_trace <- function(state, L, theta.lower, theta.upper) {
  grid <- istem_loglik_grid(state, L, theta.lower, theta.upper)
  prob <- model.MGGUM(grid$theta.norm, state$par)
  istem_loglik_indexed_sum(
    prob, state$response, state$response.count, grid$pi,
    block_sizes = state$length.poly, response_base = 0L
  )
}

fcmirt_istem_loglik_trace <- function(state, L, theta.lower, theta.upper) {
  grid <- istem_loglik_grid(state, L, theta.lower, theta.upper)
  prob <- model.FCMIRT(grid$theta.norm, state$par,
                       state$patterns.total, state$patterns)
  istem_loglik_indexed_sum(
    prob, state$response, state$response.count, grid$pi,
    block_sizes = vapply(state$patterns, nrow, integer(1L)),
    response_base = 1L
  )
}

fcggum_istem_loglik_trace <- function(state, L, theta.lower, theta.upper) {
  grid <- istem_loglik_grid(state, L, theta.lower, theta.upper)
  prob <- model.FCGGUM(grid$theta.norm, state$par,
                       state$patterns.total, state$patterns)
  istem_loglik_indexed_sum(
    prob, state$response, state$response.count, grid$pi,
    block_sizes = vapply(state$patterns, nrow, integer(1L)),
    response_base = 1L
  )
}

tirt_istem_loglik_trace <- function(state, L, theta.lower, theta.upper) {
  grid <- istem_loglik_grid(state, L, theta.lower, theta.upper)
  gamma.matrix <- tirt_istem_gamma_matrix(
    state$gamma, rep(NA_real_, length(state$gamma)),
    state$pairs.matrix, state$I.states
  )$est
  prob <- model.TIRT(grid$theta.norm, state$lambda, state$psi^2,
                     gamma.matrix, state$Q.matrix, state$pairs.matrix)

  response <- as.matrix(state$response.unique)
  block.pair.counts <- vapply(seq_along(state$block.items), function(b) {
    k <- length(state$block.items[[b]])
    as.integer(switch(state$fc.type[b],
      RANK = choose(k, 2),
      MOLE = 2L * k - 3L,
      PICK = k - 1L,
      stop("'fc.type' must be RANK, MOLE, or PICK.", call. = FALSE)
    ))
  }, integer(1L))

  if (!is.null(state$pairs.value.unique) && !all(state$fc.type == "RANK")) {
    lik <- cpp_loglik_binary_person_pairs(
      prob = prob,
      response = matrix(as.integer(response), nrow = nrow(response),
                        ncol = ncol(response)),
      pi = as.vector(grid$pi),
      pairs_matrix = state$pairs.matrix,
      pairs_value = state$pairs.value.unique,
      block_pair_counts = block.pair.counts
    )
  } else {
    lik <- cpp_loglik_binary(
      prob = prob,
      response = matrix(as.integer(response), nrow = nrow(response),
                        ncol = ncol(response)),
      pi = as.vector(grid$pi)
    )
  }
  loglik_apply_group_count(lik, state$response.count)$logLik
}

istem_prepare_01_q <- function(I, D, Q.matrix, triangular = TRUE,
                               require.row = FALSE) {
  if (!is.null(Q.matrix)) {
    Q.matrix <- as.matrix(Q.matrix)
    if (nrow(Q.matrix) != I || ncol(Q.matrix) != D ||
        anyNA(Q.matrix) || !all(Q.matrix %in% c(0, 1))) {
      stop("'Q.matrix' must be an I x D matrix containing only 0 and 1.",
           call. = FALSE)
    }
  }

  if (is.null(Q.matrix)) {
    if (triangular && D > I) {
      stop("Triangular identification requires 'D' <= number of items.",
           call. = FALSE)
    }
    Q.matrix <- matrix(1, I, D)
    if (triangular && D > 1L) {
      for (k in seq_len(D)) {
        r <- I - D + k
        for (j in seq_len(D)) {
          if (k > D - j + 1L) {
            Q.matrix[r, j] <- 0
          }
        }
      }
    }
  }

  if (require.row && any(rowSums(Q.matrix) < 1L)) {
    stop("Each item must measure at least one trait (each row of 'Q.matrix' needs a 1).",
         call. = FALSE)
  }

  storage.mode(Q.matrix) <- "numeric"
  Q.matrix
}

istem_prepare_signed_q <- function(I, D, Q.matrix, require.row = FALSE) {
  if (!is.null(Q.matrix)) {
    Q.matrix <- as.matrix(Q.matrix)
    if (nrow(Q.matrix) != I || ncol(Q.matrix) != D ||
        anyNA(Q.matrix) || !all(Q.matrix %in% c(-1, 0, 1))) {
      stop("'Q.matrix' must be an I x D matrix containing only -1, 0, and 1.",
           call. = FALSE)
    }
  }
  if (is.null(Q.matrix)) {
    Q.matrix <- matrix(1, I, D)
  }
  if (require.row && any(rowSums(abs(Q.matrix)) < 1L)) {
    stop("Each item must measure at least one trait (each row of 'Q.matrix' needs a non-zero entry).",
         call. = FALSE)
  }
  storage.mode(Q.matrix) <- "numeric"
  Q.matrix
}

istem_prepare_response <- function(response, binary = FALSE) {
  response <- as.matrix(response)
  if (!is.numeric(response) || length(dim(response)) != 2L ||
      nrow(response) < 1L || ncol(response) < 1L) {
    stop("'data' must be a non-empty numeric matrix.", call. = FALSE)
  }
  if (anyNA(response) || any(!is.finite(response))) {
    stop("'data' must not contain missing or non-finite responses.",
         call. = FALSE)
  }
  if (any(response != floor(response))) {
    stop("'data' responses must be integer-valued.", call. = FALSE)
  }
  if (binary) {
    if (any(!response %in% c(0, 1))) {
      stop("'data' must contain only binary responses coded 0 and 1.",
           call. = FALSE)
    }
  } else if (any(response < 0)) {
    stop("'data' responses must be non-negative integers.", call. = FALSE)
  }
  storage.mode(response) <- "integer"
  response
}

istem_prepare_length_poly <- function(response, length.poly = NULL) {
  response <- istem_prepare_response(response, binary = FALSE)
  observed.length.poly <- apply(response, 2, max, na.rm = TRUE) + 1L
  I <- ncol(response)
  if (is.null(length.poly)) {
    length.poly <- observed.length.poly
  } else {
    if (length(length.poly) != I) {
      if (length(length.poly) == 1L) {
        length.poly <- rep(length.poly, I)
      } else {
        stop("'length.poly' must be a scalar or have length equal to ncol(response).",
             call. = FALSE)
      }
    }
    if (!is.numeric(length.poly) || anyNA(length.poly) ||
        any(!is.finite(length.poly)) || any(length.poly != floor(length.poly))) {
      stop("'length.poly' must contain finite integers.", call. = FALSE)
    }
    length.poly <- as.integer(length.poly)
    if (any(length.poly < 2L)) {
      stop("'length.poly' must contain integers >= 2.", call. = FALSE)
    }
    if (any(observed.length.poly > length.poly)) {
      stop("'length.poly' is smaller than the largest observed response category for at least one item.",
           call. = FALSE)
    }
  }
  if (any(length.poly < 2L)) {
    stop("Every item must have at least two response categories.",
         call. = FALSE)
  }
  as.integer(length.poly)
}

istem_named_theta <- function(theta, theta.se, response, D) {
  colnames(theta) <- colnames(theta.se) <- paste0("Dim.", seq_len(D))
  rownames(theta) <- rownames(theta.se) <- rownames(response)
  theta.Rhat <- matrix(NA_real_, nrow(theta), D, dimnames = dimnames(theta))
  list(est = theta, se = theta.se, Rhat = theta.Rhat)
}

istem_param_corr_vec <- function(Corr, include_corr) {
  if (include_corr) Corr[lower.tri(Corr)] else numeric(0)
}

istem_unpack_corr <- function(pv, pv_se, idx, D, Corr, include_corr) {
  Corr.se <- matrix(NA_real_, D, D)
  if (D == 1L) {
    Corr <- matrix(1, 1, 1)
    Corr.se <- matrix(0, 1, 1)
  } else if (include_corr) {
    n_corr <- D * (D - 1L) / 2L
    corr_vals <- pv[idx:(idx + n_corr - 1L)]
    corr_se <- pv_se[idx:(idx + n_corr - 1L)]
    idx <- idx + n_corr
    Corr <- diag(D)
    Corr[lower.tri(Corr)] <- corr_vals
    Corr <- t(Corr)
    Corr[lower.tri(Corr)] <- corr_vals
    Corr.se[lower.tri(Corr.se)] <- corr_se
    Corr.se <- t(Corr.se)
    Corr.se[lower.tri(Corr.se)] <- corr_se
    diag(Corr.se) <- 0
    Corr <- istem_safe_corr(Corr)
  } else {
    Corr.se[,] <- NA_real_
    diag(Corr.se) <- 0
  }
  list(Corr = Corr, Corr.se = Corr.se, idx = idx)
}

istem_unpack_corr_rhat <- function(pv_rhat, idx, D, include_corr) {
  if (is.null(pv_rhat)) return(matrix(NA_real_, D, D))
  Corr.rhat <- matrix(NA_real_, D, D)
  diag(Corr.rhat) <- NA_real_
  if (D > 1L && include_corr) {
    n_corr <- D * (D - 1L) / 2L
    corr_rhat <- pv_rhat[idx:(idx + n_corr - 1L)]
    Corr.rhat[lower.tri(Corr.rhat)] <- corr_rhat
    Corr.rhat <- t(Corr.rhat)
    Corr.rhat[lower.tri(Corr.rhat)] <- corr_rhat
  }
  Corr.rhat
}


################################# Forced-choice iStEM helpers #################################

istem_prepare_fc_data <- function(data, block.items = NULL, fc.type = "RANK") {
  fc.type.valid <- c("RANK", "MOLE", "PICK")
  fc.type <- toupper(fc.type)
  if (!all(fc.type %in% fc.type.valid)) {
    stop("'fc.type' must be one of: ", paste(fc.type.valid, collapse = ", "),
         call. = FALSE)
  }

  data.matrix <- as.matrix(data)
  N.block.data <- ncol(data.matrix)
  if (is.null(N.block.data) || N.block.data < 1L) {
    stop("'data' must have at least one column/block.", call. = FALSE)
  }
  if (length(fc.type) != N.block.data) {
    fc.type <- rep(fc.type[1L], N.block.data)
  }

  is.pattern.index.data <- is.numeric(data.matrix) || is.integer(data.matrix)
  if (is.null(block.items)) {
    if (is.pattern.index.data) {
      stop("'block.items' is required when 'data' is an integer matrix of pattern indices.",
           call. = FALSE)
    }
    if (any(fc.type != "RANK")) {
      stop("'block.items' is required for MOLE/PICK data because partial rankings do not identify all block items.",
           call. = FALSE)
    }
    block.items <- get.block.items.from.data(data)
  }

  if (is.pattern.index.data) {
    response <- matrix(as.integer(data.matrix),
                       nrow = nrow(data.matrix),
                       ncol = ncol(data.matrix),
                       dimnames = dimnames(data.matrix))
  } else {
    response <- get.response.from.data(data, block.items, fc.type = fc.type)
  }

  if (!is.list(block.items)) {
    stop("'block.items' must be a list.", call. = FALSE)
  }
  N.block <- length(block.items)
  all_items <- unlist(block.items, use.names = FALSE)
  if (anyDuplicated(all_items)) {
    stop("'block.items' contains duplicate items.", call. = FALSE)
  }
  I <- length(all_items)
  if (!setequal(all_items, seq_len(I))) {
    stop("'block.items' must contain exactly the item indices 1 through ", I,
         ".", call. = FALSE)
  }
  if (any(vapply(block.items, length, integer(1L)) < 2L)) {
    stop("Each block in 'block.items' must contain at least two items.",
         call. = FALSE)
  }

  response <- as.matrix(response)
  if (ncol(response) != N.block) {
    stop("'response' must have ", N.block, " columns (one per block).",
         call. = FALSE)
  }
  if (length(fc.type) != N.block) {
    fc.type <- rep(fc.type[1L], N.block)
  }

  patterns.total <- vector("list", N.block)
  patterns <- vector("list", N.block)
  for (b in seq_len(N.block)) {
    patterns.total[[b]] <- get_permutations(block.items[[b]],
                                            length(block.items[[b]]))
    patterns[[b]] <- get_permutations(
      block.items[[b]],
      switch(fc.type[b],
             RANK = length(block.items[[b]]),
             MOLE = 2L,
             PICK = 1L)
    )
  }

  for (b in seq_len(N.block)) {
    if (anyNA(response[, b]) ||
        any(response[, b] < 1L | response[, b] > nrow(patterns[[b]]))) {
      stop("'data' contains invalid pattern indices for block ", b,
           "; valid values are 1 through ", nrow(patterns[[b]]), ".",
           call. = FALSE)
    }
  }

  storage.mode(response) <- "integer"

  list(
    response = response,
    block.items = block.items,
    fc.type = fc.type,
    patterns = patterns,
    patterns.total = patterns.total,
    N = nrow(response),
    B = N.block,
    I = I,
    all.items = all_items
  )
}

Try the ForceChoice package in your browser

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

ForceChoice documentation built on Sept. 13, 2026, 1:06 a.m.