R/control.R

Defines functions format.efa_rotate_control print.efa_rotate_control format.efa_estimate_control print.efa_estimate_control .control_knob_line .repack_flat_dots .drop_unknown_frozen_dots .assert_estimate_control .reject_renamed_method .reject_rotation_dots .reject_inference_dots .reject_unknown_fit_dots .reject_flat_knobs rotate_control estimate_control .assert_control_flag .map_subset_ci .match_arg_ci .fold_upper .match_control_choice .assert_control_number .assert_control_choice .control_choice_index .is_control_unset

Documented in estimate_control format.efa_estimate_control format.efa_rotate_control print.efa_estimate_control print.efa_rotate_control rotate_control

# Declarative control objects for the estimation and rotation tuning knobs.
#
# `estimate_control()` and `rotate_control()` bundle the tuning arguments that are
# otherwise passed one by one to a fit. Each returns a validated, classed list that
# is a 1:1 declarative surface over the settings resolved internally by
# `.estimate_model()` (via `.PAF()`) and `.rotate_model()`. The constructors only
# validate and store: they carry the chosen `type` preset and the declared knobs
# (with `NA` marking "unset, resolve from the preset") and leave the actual preset
# resolution -- and any override warning -- to the fitting cores, because which
# preset applies depends on the estimator and rotation, which a control object does
# not know.

# TRUE when a knob is left unset: a single genuine NA, the "resolve from the type
# preset" sentinel. NaN is deliberately not treated as unset (is.na(NaN) is TRUE) so a
# stray NaN falls through to the type check and is rejected rather than silently stored.
.is_control_unset <- function(x) length(x) == 1L && is.na(x) && !is.nan(x)

# Index of a single knob value among its choices, case-folded and allowing unambiguous
# abbreviations. NA marks "no usable match": a non-string input, no match at all, or -- from
# charmatch()'s 0 -- an abbreviation that prefixes several choices.
.control_choice_index <- function(x, choices) {
  if (!checkmate::test_string(x)) return(NA_integer_)
  idx <- charmatch(.fold_upper(x), .fold_upper(choices))
  if (!is.na(idx) && idx == 0L) NA_integer_ else idx
}

# Resolve a choice-valued knob (or NA, when `na_ok`) against its choices, case-insensitively
# and allowing unambiguous abbreviations as everywhere else on the interface, and return the
# canonical spelling so the stored knob and the printed control always carry it. Classed abort
# otherwise.
.assert_control_choice <- function(x, choices, arg, na_ok = TRUE) {
  if (na_ok && .is_control_unset(x)) return(x)
  idx <- .control_choice_index(x, choices)
  if (is.na(idx)) {
    na_txt <- if (na_ok) " (or NA to resolve from the type preset)" else ""
    cli::cli_abort(
      "{.arg {arg}} must be one of {.val {choices}}{na_txt}.",
      class = "efa_control_input"
    )
  }
  choices[idx]
}

# Validate a numeric knob against its admissible range (optionally a whole number, optionally
# NA); classed abort otherwise. `positive` demands x > 0; `lower` bounds it inclusively and
# `upper` inclusively unless `upper_strict`, which demands x < upper. The bounds mirror the ones
# the fitting core enforces, so a control object can never carry a value the fit would go on to
# reject.
.assert_control_number <- function(x, arg, positive = TRUE, lower = -Inf, upper = Inf,
                                   na_ok = TRUE, int = FALSE, upper_strict = FALSE) {
  if (na_ok && .is_control_unset(x)) return(invisible(NULL))
  ok <- if (int) checkmate::test_int(x) else checkmate::test_number(x, finite = TRUE)
  if (isTRUE(ok) && positive) ok <- x > 0
  if (isTRUE(ok)) ok <- x >= lower && if (upper_strict) x < upper else x <= upper
  if (!isTRUE(ok)) {
    kind <- if (int) "whole number" else "number"
    bounds <- character()
    if (positive) bounds <- c(bounds, "greater than 0")
    if (!positive && is.finite(lower)) bounds <- c(bounds, paste("of at least", lower))
    if (is.finite(upper)) {
      bounds <- c(bounds, paste(if (upper_strict) "smaller than" else "of at most", upper))
    }
    bound <- if (length(bounds)) paste0(" ", paste(bounds, collapse = " and ")) else ""
    na_txt <- if (na_ok) " (or NA to resolve from the type preset)" else ""
    cli::cli_abort(
      "{.arg {arg}} must be a single {kind}{bound}{na_txt}.",
      class = "efa_control_input"
    )
  }
  invisible(NULL)
}

# Resolve a choice-valued knob that the flat interface partial-matched, keeping that
# convenience: an abbreviation resolves to the full value and NA stays unset (it means "not
# needed by this estimator"). Returns the resolved value; classed abort otherwise.
.match_control_choice <- function(x, choices, arg) {
  if (.is_control_unset(x)) return(x)
  idx <- .control_choice_index(x, choices)
  if (is.na(idx)) {
    cli::cli_abort(
      "{.arg {arg}} must be one of {.val {choices}} (or NA to leave it unset).",
      class = "efa_control_input"
    )
  }
  choices[idx]
}

# ASCII-only upper-casing for case-folded matching. toupper() is locale-dependent -- under a
# Turkish/Azeri locale toupper("i") is the dotted capital "İ", so an input like "minres"
# or "oblimin" would fold to a string the (already-uppercase) choice can never equal. The
# choice values are all ASCII, so folding both sides with a fixed a-z -> A-Z map keeps the
# matching identical on every platform.
.fold_upper <- function(x) chartr("abcdefghijklmnopqrstuvwxyz",
                                  "ABCDEFGHIJKLMNOPQRSTUVWXYZ", x)

# Case-insensitive match.arg(): resolve a choice-valued argument against its choices and
# return the canonical spelling(s) from `choices`, so downstream code, stored settings, and
# printed output always carry the canonical value. Values are compared case-folded, and an
# exact (case-folded) match beats a prefix match, so unambiguous abbreviations still resolve
# as they do under match.arg(). It is deliberately stricter than match.arg() in `several.ok`
# mode: an unmatched element aborts here, whereas match.arg() silently drops it. Mirroring
# match.arg(), an `arg` identical to `choices` selects the default -- the first choice, or
# all of them when `several.ok` -- and a missing `choices` is taken from the caller's formal
# default; a NULL `arg` selects the documented default too, see the comment on that branch.
# Ambiguous or unmatched values are a classed abort listing the choices; `class` lets a
# caller raise its own condition class (the control constructors reuse their
# `efa_control_input`).
.match_arg_ci <- function(arg, choices, several.ok = FALSE,
                          arg_name = deparse1(substitute(arg)),
                          class = "efa_bad_choice") {
  caller <- sys.parent()
  from_formals <- missing(choices)
  if (from_formals) {
    choices <- eval(formals(sys.function(caller))[[arg_name]],
                    envir = sys.frame(caller))
  }
  # NULL means "use the default", as in match.arg(), but the default is the caller's formal
  # default, NOT choices[1L]: the two part company under `several.ok` whenever the formal
  # default is a whole vector (efa_hull()'s `gof`, efa_kgc()'s `eigen_type`), and wherever an
  # explicit `choices` is a superset of the formal default (efa_retain()'s `criteria`) or
  # orders it differently (`eigen_type_other`). Taking choices[1L] there silently runs
  # something narrower than the help page documents, with no condition raised. A formal
  # without a default, or one that is itself NULL (efa_simulate()'s `match`, documented as
  # resolving to "thresholds"), falls back to choices[1L].
  if (is.null(arg)) {
    if (from_formals) {
      # `choices` already is that default -- no need to look it up a second time
      arg <- choices
    } else {
      # Keep the default inside a length-one list while testing it: a formal without a
      # default is the empty symbol, and binding that to a variable (or passing it on)
      # forces it into R's "argument is missing" error. Comparing the enclosing list never
      # evaluates it.
      default <- formals(sys.function(caller))[arg_name]
      arg <- if (identical(unname(as.list(default)), list(quote(expr = )))) {
        NULL
      } else {
        eval(default[[1L]], envir = sys.frame(caller))
      }
    }
    if (is.null(arg)) return(choices[1L])
  }
  if (identical(arg, choices)) return(if (several.ok) choices else choices[1L])

  # charmatch(): NA = no match, 0 = ambiguous (a prefix of several choices)
  idx <- if (is.character(arg)) charmatch(.fold_upper(arg), .fold_upper(choices)) else NA_integer_
  len_ok <- if (several.ok) length(arg) >= 1L else length(arg) == 1L
  if (!len_ok || anyNA(idx) || any(idx == 0L)) {
    no_match <- if (is.character(arg)) unique(arg[is.na(idx)]) else character()
    ambiguous <- if (is.character(arg)) unique(arg[!is.na(idx) & idx == 0L]) else character()
    what <- if (several.ok) "one or more" else "one"
    cli::cli_abort(
      c("{.arg {arg_name}} must be {what} of {.val {choices}}.",
        if (!is.character(arg)) c("x" = "You supplied {.obj_type_friendly {arg}}."),
        if (!several.ok && is.character(arg) && length(arg) > 1L) {
          c("x" = "You supplied {length(arg)} values.")
        },
        if (length(no_match) > 0L) {
          c("x" = "{.val {no_match}} {?does/do} not match any of the choices.")
        },
        if (length(ambiguous) > 0L) {
          c("x" = "{.val {ambiguous}} {?matches/match} more than one choice.")
        }),
      class = class
    )
  }
  choices[idx]
}

# Case-insensitively map the elements of a vector-valued choice argument onto the canonical
# spelling of `choices` (exact case-folded matches only -- no abbreviations, matching the
# subset semantics of the callers). An element matching no choice is returned unchanged, so
# the caller's subset assertion still rejects it with its usual message.
.map_subset_ci <- function(x, choices) {
  if (!is.character(x)) return(x)
  idx <- match(.fold_upper(x), .fold_upper(choices))
  found <- !is.na(idx)
  x[found] <- choices[idx[found]]
  x
}

# Validate a logical knob (or NA, when `na_ok`); classed abort otherwise.
.assert_control_flag <- function(x, arg, na_ok = TRUE) {
  if (na_ok && .is_control_unset(x)) return(invisible(NULL))
  if (!checkmate::test_flag(x)) {
    na_txt <- if (na_ok) " (or NA to resolve from the type preset)" else ""
    cli::cli_abort(
      "{.arg {arg}} must be a single logical value{na_txt}.",
      class = "efa_control_input"
    )
  }
  invisible(NULL)
}

#' Control objects for estimation and rotation settings
#'
#' `estimate_control()` and `rotate_control()` collect the estimation and rotation
#' *tuning* arguments of a factor analysis into two small, validated objects. They
#' are a declarative surface over the same settings resolved internally by the
#' package's estimation and rotation engines, so that a fit's many tuning knobs can
#' be prepared, inspected, and reused as a single value instead of being passed one
#' by one.
#'
#' Each argument that governs a `type` preset defaults to `NA`, meaning "leave this
#' knob to the preset". Setting `type` to one of `"EFAtools"`, `"psych"`, or
#' `"SPSS"` fills those knobs from the corresponding preset when the fit is run;
#' setting `type = "none"` requires the relevant knobs to be supplied explicitly.
#' The control object only records the chosen `type` and the knobs you set: the
#' preset is resolved (and any "argument set alongside `type`" warning issued) when
#' the object is used to fit a model, exactly as it is today, because which preset
#' applies depends on the estimator and rotation.
#'
#' @param type character. One of `"EFAtools"` (default), `"psych"`, `"SPSS"`, or
#'   `"none"`. Selects the preset that fills the `NA`-defaulted knobs below when the
#'   control is used to fit a model.
#' @param init_comm character. Method for the initial communalities in principal
#'   axis factoring: `"smc"` (squared multiple correlations), `"mac"` (maximum
#'   absolute correlations), or `"unity"`. `NA` (default) resolves from `type`.
#' @param criterion numeric. The convergence criterion for principal axis
#'   factoring: iteration stops once the change in communalities falls below it. A
#'   single number greater than 0 and smaller than 1; `NA` (default) resolves from `type`.
#' @param criterion_type character. The convergence criterion type for principal
#'   axis factoring: `"max_individual"` (the largest change in any communality, as
#'   in SPSS) or `"sum"` (the change in the summed communalities, as in
#'   [psych::fa()]). `NA` (default) resolves from `type`.
#' @param max_iter numeric. The maximum number of principal-axis-factoring
#'   iterations before the procedure is halted with a warning. A single whole
#'   number of at least 1; `NA` (default) resolves from `type`.
#' @param abs_eigen logical. Which algorithm the principal-axis-factoring
#'   iterations use: `FALSE` computes the loadings from the eigenvalues (as in
#'   [psych::fa()]); `TRUE` uses the absolute eigenvalues (as in SPSS). `NA`
#'   (default) resolves from `type`.
#' @param start_method character. Starting values for the maximum-likelihood
#'   optimiser: `"psych"` (default, the [psych::fa()] starts) or `"factanal"` (the
#'   [stats::factanal()] starts); abbreviations are matched. Not governed by `type`.
#'   Only maximum likelihood uses it, so `NA` leaves it unset and is rejected only by
#'   a fit that is actually run with `estimator = "ML"`.
#' @param fiml_max_iter numeric. The maximum number of EM iterations used to estimate the
#'   two-stage full-information maximum-likelihood moments from raw data with missing
#'   values (`cor_method = "fiml"`); the last iterate is returned, with a warning, if the
#'   cap is reached. A single whole number of at least 1; default `500`. Not governed by
#'   `type`, and unused by every other correlation method. The EM converges linearly and
#'   needs more iterations the larger the fraction of missing information, so raise it when
#'   a fit reports that the moments did not converge.
#' @param fiml_tol numeric. The convergence tolerance of that EM: iteration stops once the
#'   largest change in the standardized moments (the standardized means, log-variances, and
#'   correlations) falls below it, so it does not depend on the variables' measurement
#'   scale. A single number greater than 0 and smaller than 1 (at or above 1 the criterion is
#'   met immediately and the starting moments would be returned as converged); default `1e-5`.
#'   Not governed by `type`.
#'
#' @returns `estimate_control()` returns a list of class `efa_estimate_control` with
#'   the components `type`, `init_comm`, `criterion`, `criterion_type`, `max_iter`,
#'   `abs_eigen`, `start_method`, `fiml_max_iter`, and `fiml_tol`.
#'   `rotate_control()` returns a list of class
#'   `efa_rotate_control` with the components `type`, `normalize`, `precision`,
#'   `order_type`, `varimax_type`, `p_type`, `k`, `random_starts`, and `extra_args`
#'   (a named list of any additional arguments forwarded to the rotation engine).
#'
#' @seealso [efa_fit()], which takes both controls; [efa_retain()], the retention
#'   criteria, and [efa_schmid_leiman()], which take an `estimate_control` for the
#'   fits they run.
#'
#' @family Control functions
#'
#' @examples
#' # Estimation knobs taken entirely from a preset:
#' estimate_control(type = "SPSS")
#'
#' # A preset with one knob pinned to a non-preset value:
#' estimate_control(type = "EFAtools", max_iter = 500)
#'
#' # Every knob supplied explicitly (type = "none"):
#' estimate_control(type = "none", init_comm = "smc", criterion = 1e-3,
#'                  criterion_type = "sum", max_iter = 300, abs_eigen = TRUE)
#'
#' @export
estimate_control <- function(type = c("EFAtools", "psych", "SPSS", "none"),
                             init_comm = NA, criterion = NA, criterion_type = NA,
                             max_iter = NA, abs_eigen = NA, start_method = "psych",
                             fiml_max_iter = 500, fiml_tol = 1e-5) {

  # Match `type` case-insensitively, but keep the constructor's own condition class so a bad
  # `type` is caught alongside the other knobs (all `efa_control_input`).
  type <- .match_arg_ci(type, class = "efa_control_input")

  init_comm <- .assert_control_choice(init_comm, c("smc", "mac", "unity"), "init_comm")
  # The convergence tolerance is compared against a change in communalities, so a value at or
  # above 1 stops the iterations immediately; the fit rejects it (see .PAF()), and the control
  # must not be able to carry a value the fit would go on to reject.
  .assert_control_number(criterion, "criterion", upper = 1, upper_strict = TRUE)
  criterion_type <- .assert_control_choice(criterion_type, c("max_individual", "sum"),
                                           "criterion_type")
  .assert_control_number(max_iter, "max_iter", int = TRUE)
  .assert_control_flag(abs_eigen, "abs_eigen")
  # `start_method` only governs the ML optimiser, so NA ("not needed here") is admissible and
  # is rejected by the fit itself, and only when `estimator = "ML"`.
  start_method <- .match_control_choice(start_method, c("psych", "factanal"), "start_method")
  # The two FIML knobs are not preset-driven, so they carry real defaults rather than the NA
  # sentinel and NA is not admissible. The tolerance is bounded above at 1 for the same reason
  # `criterion` is: it is compared against a change in standardized moments -- standardized means,
  # log-variances, and correlations -- so a value at or above 1 is met on the first iteration and
  # the EM would return its starting moments while reporting itself converged. Unlike `criterion`
  # the fit does not reject such a value downstream (the EM only demands tol > 0), so the bound has
  # to be enforced here or not at all.
  .assert_control_number(fiml_max_iter, "fiml_max_iter", int = TRUE, na_ok = FALSE)
  .assert_control_number(fiml_tol, "fiml_tol", upper = 1, upper_strict = TRUE, na_ok = FALSE)

  structure(
    list(type = type, init_comm = init_comm, criterion = criterion,
         criterion_type = criterion_type, max_iter = max_iter,
         abs_eigen = abs_eigen, start_method = start_method,
         fiml_max_iter = fiml_max_iter, fiml_tol = fiml_tol),
    class = "efa_estimate_control"
  )
}

#' @rdname estimate_control
#'
#' @param normalize logical. If `TRUE` (default), a Kaiser normalization is
#'   performed before the rotation. The one knob that is always on unless you turn
#'   it off with `FALSE`.
#' @param precision numeric. The convergence tolerance of the rotation procedure. A
#'   single number greater than 0 and at most 1; default `1e-5`. Each rotation stage
#'   monitors its own quantity, so the same number is not the same tolerance everywhere:
#'   `varimax_type = "kaiser"` stops on the *absolute* change in the varimax simplicity
#'   criterion, which is an average over variables (and so does not scale with how many
#'   there are) but rises with the number of factors, roughly toward
#'   `1 - 1 / n_factors`, so a fixed value is a relatively weaker tolerance the more
#'   factors are extracted; `varimax_type = "svd"` stops on the *relative* change in the
#'   singular values (as in [stats::varimax()]); and the criterion rotations fitted by
#'   gradient projection stop when the *projected-gradient norm* falls below it. Promax
#'   inherits whichever of the two varimax tests its `varimax_type` selects, because it
#'   rotates a varimax base.
#' @param order_type character. How the factors are ordered: `"eigen"` (by
#'   descending sum of squared loadings, as in [psych::fa()]) or `"ss_factors"` (by
#'   descending unweighted sum of squared loadings). `NA` (default) resolves from
#'   `type`.
#' @param varimax_type character. The varimax variant used (for the varimax and
#'   promax rotations): `"svd"` (as in [stats::varimax()]) or `"kaiser"` (the SPSS
#'   / Kaiser (1958) procedure). `NA` (default) resolves from `type`.
#' @param p_type character. How the promax target matrix is computed: `"unnorm"`
#'   (the unnormalized target of Hendrickson & White (1964), also used by psych and
#'   stats) or `"norm"` (the normalized target used by SPSS). `NA` (default)
#'   resolves from `type`.
#' @param k numeric. The promax power (for the target matrix) or the number of
#'   near-zero loadings for simplimax. A single number greater than 0; `NA`
#'   (default) leaves it to the fit (the `type`-dependent promax value, or
#'   `nrow(loadings)` for simplimax). Simplimax counts loadings, so a fit using it
#'   additionally requires a whole number no larger than the number of loadings in
#'   the solution; promax's power has no such restriction.
#' @param random_starts numeric. The number of random starts used by the
#'   criterion-based rotations to guard against local minima. A single whole number
#'   of at least 0, where `0` runs the rotation from its warm start only; default
#'   `100`. The default suffices for the smooth criteria; `simplimax` remains
#'   materially start-dependent at it, so raise it there (see the *Rotations* section
#'   of [efa_fit()]).
#' @param ... Additional arguments forwarded to the rotation engine. Only the names
#'   a rotation engine can consume are accepted: `maxit` (a whole number of at least 0
#'   bounding a *single* gradient-projection optimization -- the multi-start search runs
#'   several of them and each is bounded separately, so it is not a budget for the run as a
#'   whole; varimax and promax have no such stage and take only `precision`), and the
#'   criterion parameters `gam` (oblimin; `gam = 0` is the
#'   recommended default, and larger values increasingly reward correlated factors and
#'   can drive the solution toward factor collapse, so inspect `Phi` before interpreting
#'   a fit with `gam > 0`) and `delta` (geomin; a positive number, default `0.01`);
#'   anything else is rejected as a
#'   misspelling. They are stored in `extra_args` and passed on to the rotation engine
#'   when the control is used to fit a model; an extra a given fit's rotation does not
#'   consume is ignored by that fit, so one control can serve fits with different
#'   rotations. An estimation knob (which belongs in [estimate_control()]) or one of the
#'   former spellings `P_type` and `randomStarts` is likewise rejected here, because the
#'   fit would silently drop it.
#'
#' @examples
#'
#' # Rotation knobs taken from a preset:
#' rotate_control(type = "psych")
#'
#' # A criterion-specific extra argument, forwarded to the rotation engine:
#' rotate_control(type = "EFAtools", k = 3, gam = 0.5)
#'
#' @export
rotate_control <- function(type = c("EFAtools", "psych", "SPSS", "none"),
                           normalize = TRUE, precision = 1e-5, order_type = NA,
                           varimax_type = NA, p_type = NA, k = NA,
                           random_starts = 100, ...) {

  type <- .match_arg_ci(type, class = "efa_control_input")

  .assert_control_flag(normalize, "normalize", na_ok = FALSE)
  .assert_control_number(precision, "precision", upper = 1, na_ok = FALSE)
  order_type <- .assert_control_choice(order_type, c("eigen", "ss_factors"), "order_type")
  varimax_type <- .assert_control_choice(varimax_type, c("svd", "kaiser"), "varimax_type")
  p_type <- .assert_control_choice(p_type, c("norm", "unnorm"), "p_type")
  .assert_control_number(k, "k")
  # 0 is a meaningful setting, not a missing one: it runs the rotation from its warm start
  # only, with no random restarts.
  .assert_control_number(random_starts, "random_starts", positive = FALSE, lower = 0,
                         int = TRUE, na_ok = FALSE)

  extra_args <- list(...)
  # A misplaced tuning knob is not a rotation engine extra: its name collides with a formal
  # of the fit, so the extras splice in efa_fit() would drop it and the fit would quietly run
  # the preset value instead. Refuse to carry it rather than let it be silently ignored.
  bad_est <- intersect(names(extra_args), .flat_estimate_knobs)
  bad_flat <- intersect(names(extra_args), c("P_type", "randomStarts"))
  if (length(bad_est) > 0L || length(bad_flat) > 0L) {
    cli::cli_abort(
      c("{.arg {c(bad_est, bad_flat)}} cannot be passed to {.fn rotate_control} as a rotation
         engine extra.",
        if (length(bad_est) > 0L) c("i" = "The estimation knobs live in {.fn estimate_control}."),
        if (length(bad_flat) > 0L) c("i" = "The former {.arg P_type} and {.arg randomStarts} are
                                            the formals {.arg p_type} and {.arg random_starts}.")),
      class = "efa_control_input"
    )
  }
  # Any other extra must be a name some rotation's engine can consume (the engines read their
  # extras by exact name); an unknown one is a misspelling that the fit would silently drop.
  # Deliberately validated against the across-rotation union, not a single rotation: a control
  # is reusable across fits, so it may legitimately carry an extra that some of its fits (e.g.
  # the unrotated or promax rows of an efa_average() grid) never consume.
  extra_nms <- names(extra_args)
  if (is.null(extra_nms)) extra_nms <- rep("", length(extra_args))
  bad_extra <- unique(setdiff(extra_nms[nzchar(extra_nms)], .rotation_extra_union))
  if (length(bad_extra) > 0L || any(!nzchar(extra_nms))) {
    msg <- if (length(bad_extra) > 0L) {
      "{.arg {bad_extra}} {?is/are} not {?a name/names} a rotation engine can consume."
    } else {
      "The extra engine arguments in {.arg ...} must be named."
    }
    cli::cli_abort(
      c(msg,
        "i" = "The accepted engine extras are {.arg {(.rotation_extra_union)}}.",
        "i" = "Check for a misspelled engine argument (for example {.arg gam}, not
               {.arg gamma}); the rotation tuning knobs are {.fn rotate_control}'s named
               arguments."),
      class = "efa_control_input"
    )
  }

  structure(
    list(type = type, normalize = normalize, precision = precision,
         order_type = order_type, varimax_type = varimax_type, p_type = p_type,
         k = k, random_starts = random_starts, extra_args = extra_args),
    class = "efa_rotate_control"
  )
}

# The flat estimation and rotation tuning knobs the pre-control interface exposed, split by the
# control object each one now lives in.
.flat_estimate_knobs <- c("init_comm", "criterion", "criterion_type", "max_iter",
                          "abs_eigen", "start_method")
.flat_rotate_knobs <- c("normalize", "precision", "order_type", "varimax_type", "p_type",
                        "k", "random_starts")

# The efa_fit() arguments that govern its inference machinery (standard errors and the
# random-number stream feeding them). They are efa_fit() formals, so a caller forwarding
# `...` into a fit accepts them by construction -- which is wrong wherever the fit is only
# an internal step whose standard errors are discarded (see .reject_inference_dots()).
.fit_inference_args <- c("se", "b_boot", "ci", "seed")

# Reject a flat tuning knob that reached a fit as a bare dot. No fitting function has a formal
# for any of these any more -- they live in the control objects -- so a bare copy would be taken
# for a rotation extra and dropped, and the fit would quietly run the default preset instead
# of the requested settings. Takes the argument NAMES (`...names()`), so the dots are never
# forced; `fn` names the function the caller actually reached, so the message points at the call
# that has to change.
.reject_flat_knobs <- function(nms, fn = "efa_fit") {
  # The renamed `method` argument is checked first: it is the more specific mistake, and
  # every function that must reject the flat knobs must reject the former estimator
  # spelling too, so one call site covers both.
  .reject_renamed_method(nms, fn = fn)
  bad <- intersect(nms, c("type", .flat_estimate_knobs, .flat_rotate_knobs,
                          "P_type", "randomStarts"))
  if (length(bad) == 0L) return(invisible(NULL))
  # point the example at the constructor the offending knob actually lives in
  example <- if (all(bad %in% c(.flat_rotate_knobs, "P_type", "randomStarts"))) {
    paste0(fn, "(x, ..., rotate_control = rotate_control(k = 3))")
  } else {
    paste0(fn, "(x, ..., estimate_control = estimate_control(max_iter = 500))")
  }
  cli::cli_abort(
    c("{.arg {bad}} cannot be passed to {.fn {fn}} directly.",
      "i" = "The estimation and rotation tuning knobs live in {.fn estimate_control} and
             {.fn rotate_control}.",
      "i" = "For example: {.code {example}}."),
    class = "efa_flat_knob_in_dots"
  )
}

# Reject a `...` name that no fit could ever consume. The retention criteria and their
# orchestrator forward their `...` into efa_fit(), but only when a fit actually runs (e.g.
# eigen_type includes "EFA", or a criterion that fits a model is selected). A misspelled name
# is otherwise silently dropped and the criterion runs with the default it looks like it was
# told to change. A name that is neither an efa_fit() formal nor a rotation-engine extra is
# such a misspelling and is refused here, independently of whether a fit runs. Takes the
# argument NAMES (`...names()`) so the dots are never forced; `fn` names the function reached.
#
# It must stay lenient for the superseded wrappers, which repack their flat tuning knobs into
# `estimate_control` / `rotate_control` objects and splice those into these same dots: both
# control names are efa_fit() formals, so they are in the accepted set by construction.
.reject_unknown_fit_dots <- function(nms, fn, unrotated = FALSE) {
  nms <- nms[nzchar(nms)]
  if (length(nms) == 0L) return(invisible(NULL))
  known <- c(setdiff(names(formals(efa_fit)), "..."), .rotation_extra_union)
  # A caller whose fits are always unrotated runs no rotation engine, so an engine extra
  # can never be consumed there. Refusing it here, rather than letting it reach the fit,
  # is what makes the error name the function the user called and arrive before the
  # criterion does its work (`rotation` itself is refused by .reject_rotation_dots()).
  # efa_fit()'s inference arguments are refused for the same reason, but with a message of
  # their own: they ARE efa_fit() arguments, so "check for a misspelled name" would be wrong.
  if (unrotated) {
    known <- setdiff(known, .rotation_extra_union)
    .reject_inference_dots(nms, fn = fn)
  }
  # setdiff() already returns unique values, so the offenders are named once each
  bad <- setdiff(nms, known)
  if (length(bad) == 0L) return(invisible(NULL))
  cli::cli_abort(
    c("{.arg {bad}} {?is/are} not {?an argument/arguments} of {.fn {fn}} or {.fn efa_fit}.",
      "i" = "Check for a misspelled argument name."),
    class = "efa_unused_dots"
  )
}

# Reject efa_fit()'s inference arguments in the `...` of a caller whose fits are internal
# steps. Two distinct hazards, both silent before this guard:
#   * `se`, `b_boot`, `ci`: the fit does compute the standard errors -- they are efa_fit()
#     formals, so they pass any whitelist built from them -- but the caller keeps only a few
#     quantities from the fit and never reports its standard errors, so the result is
#     identical and the work is thrown away (measurably: an information-based SE roughly
#     doubles an efa_kgc() run).
#   * `seed`: efa_fit() seeds itself with .set_local_seed(), which restores the caller's
#     stream when the fit returns, so the seed is spent on a fit that draws no random
#     numbers. Where the caller draws at all, that draw still comes from the caller's stream.
#     The name therefore looks like it pins the run and pins nothing at all; set.seed() is
#     what governs it.
# The hints must hold for every caller, so neither claims that this particular call fits, or
# simulates, or is a retention criterion: efa_retain() with a criterion such as MAP fits no
# model at all, efa_kgc() and efa_scree() fit one but simulate nothing, and
# efa_schmid_leiman() is not a criterion and keeps its internal fit's loadings rather than
# eigenvalues.
# Takes the argument NAMES (`...names()`), so the dots are never forced.
.reject_inference_dots <- function(nms, fn) {
  bad <- intersect(nms, .fit_inference_args)
  if (length(bad) == 0L) return(invisible(NULL))
  msg <- c("{.arg {bad}} cannot be passed to {.fn {fn}}.")
  if (any(c("se", "b_boot", "ci") %in% bad)) {
    msg <- c(msg, "i" = "Any fit run here is an internal step whose standard errors are not
                         reported, so they would be computed and then discarded.")
  }
  if ("seed" %in% bad) {
    msg <- c(msg, "i" = "The fits run here draw no random numbers, so {.arg seed} has
                         nothing to govern. Whatever else the call draws comes from the
                         caller's stream: call {.fn set.seed} before {.fn {fn}}.")
  }
  cli::cli_abort(msg, class = "efa_unused_dots")
}

# The retention criteria's fits are always unrotated, so a rotation setting passed through
# their `...` is meaningless: a user's `rotation = ...` makes a criterion fit spin up a
# rotation engine for nothing -- and a criterion-based rotation draws its random starts from
# the caller's stream, so it silently moves a seeded result -- while a `rotate_control` is
# simply ignored by the unrotated fit. Refuse
# both. The superseded N_FACTORS() wrapper still repacks a frozen `type` (or a flat rotation
# knob) into a rotate_control() object that legitimately rides through these dots, so a
# `rotate_control` that IS such a control object is exempt, as is a `NULL` (efa_fit()'s own
# "not supplied" default, which the repack passes through untouched) -- only some other value
# under that name, or any `rotation`, is refused. Takes the dots by value for that check.
.reject_rotation_dots <- function(dots, fn) {
  nms <- names(dots)
  if (is.null(nms)) return(invisible(NULL))
  bad <- character(0)
  if ("rotation" %in% nms) bad <- c(bad, "rotation")
  rc <- dots[nms == "rotate_control"]
  ok_rc <- vapply(rc, function(v) is.null(v) || inherits(v, "efa_rotate_control"), logical(1))
  if (!all(ok_rc)) bad <- c(bad, "rotate_control")
  if (length(bad) == 0L) return(invisible(NULL))
  cli::cli_abort(
    c("{.arg {bad}} cannot be passed to {.fn {fn}}.",
      "i" = "The fits run by the retention criteria are always unrotated."),
    class = "efa_unused_dots"
  )
}

# Reject the former `method` argument (the estimator is selected with `estimator`) when it
# lands in a fit function's `...`: it would otherwise be forwarded into the rotation extras
# or a criterion fit -- surfacing as an opaque downstream error or being silently ignored --
# instead of selecting the estimator.
.reject_renamed_method <- function(nms, fn = "efa_fit") {
  if (!"method" %in% nms) return(invisible(NULL))
  cli::cli_abort(
    c("{.arg method} is not an argument of {.fn {fn}}.",
      "i" = "The estimator is selected with {.arg estimator}, e.g.
             {.code {fn}(x, ..., estimator = \"ML\")}."),
    class = "efa_renamed_arg"
  )
}

# Validate an estimation control (NULL means "the efa_fit() default"). Shared by efa_fit() and by
# the functions that only pass the control on to a fit, so a bogus object is rejected even when
# the call ends up running no fit at all (e.g. efa_kgc() with eigen_type = "PCA").
.assert_estimate_control <- function(x) {
  if (is.null(x) || inherits(x, "efa_estimate_control")) return(invisible(NULL))
  cli::cli_abort(
    "{.arg estimate_control} must be a control object from {.fn estimate_control}.",
    class = "efa_control_input"
  )
}

# The legacy alias spellings .repack_flat_dots() translates rather than forwards. Kept as a
# named constant so the drop filter below and the repack agree by construction.
.flat_alias_names <- c("method", "type", "P_type", "randomStarts")

# The frozen wrappers that forward their `...` (N_FACTORS(), PARALLEL(), KGC(), SCREE(),
# HULL(), NEST(), EFA_POOLED()) keep the flat interface's silent-ignore contract: a name
# that nothing on the old surface could ever consume was dropped without comment, so it is
# dropped here too -- before .repack_flat_dots() translates the flat knobs and before the
# successors' guards (.reject_unknown_fit_dots(), .reject_rotation_dots(), efa_fit()'s
# per-rotation extras whitelist) would refuse it. The kept universe is everything the old
# surface consumed: the efa_fit() formals, the flat tuning knobs, and the alias spellings.
# Keeping the flat knobs matters when a control object rides alongside one (the repack then
# translates nothing), so such a mixed call still fails loudly in the successor's flat-knob
# guard instead of losing the knob here. The retention wrappers' criterion fits are always
# unrotated (the default), so a `rotation` setting and the rotation-engine extras
# (`maxit`/`gam`/`delta`) were never consumable there -- 0.8.0 ignored them without effect on
# the result -- and are dropped with the junk; EFA_POOLED() instead passes `unrotated =
# FALSE` plus the extras its selected rotation's engine reads, mirroring EFA()'s own
# rotation-aware filter. Keeping a name here is not the same as accepting it: efa_fit()'s
# inference arguments (`se`/`b_boot`/`ci`/`seed`) pass this filter as efa_fit() formals and
# are then refused by .reject_inference_dots() in the retention successors. That is a
# DELIBERATE exception to the silent-ignore rule above, not an instance of it: unlike the
# rotation extras, the flat EFA() did take all four and a pre-rename call could name them.
# It changes no legacy result, because on this path they were inert -- these wrappers hand
# EFA() a correlation matrix, which never reached its seeding branch at all, and a standard
# error the fit computed was thrown away by the criterion (`se` even aborted outright through
# some of them: "sandwich" for want of an acov, "information" through KGC()/SCREE() for want
# of an N). See .reject_inference_dots() for the two hazards themselves.
# A successor-only name carrying a malformed value (e.g.
# `rotate_control = "SPSS"`) is deliberately KEPT: no pre-rename code could have used that
# name, so the successor's loud classed validation is the right outcome, exactly as EFA()
# rejects the successor-only names outright. Unnamed dots pass through untouched, as on the
# reject side.
.drop_unknown_frozen_dots <- function(dots, extras = character(), unrotated = TRUE) {
  nms <- names(dots)
  if (is.null(nms)) return(dots)
  known <- c(setdiff(names(formals(efa_fit)), "..."),
             .flat_estimate_knobs, .flat_rotate_knobs, .flat_alias_names, extras)
  if (unrotated) known <- setdiff(known, "rotation")
  # charmatch(), not %in%: the flat interface reached its target through do.call(), where R
  # partial-matched an abbreviated name against the formals ahead of `...`, so an
  # abbreviation was a name the old surface could consume. Dropping it here would turn such
  # a call into a silently different fit; keeping it (an ambiguous abbreviation too, which
  # base R would also have refused) lets it travel on and fail loudly instead.
  dots[!nzchar(nms) | !is.na(charmatch(nms, known))]
}

# Repack the flat tuning knobs a call may still carry in its `...` into the control objects the
# fitting interface now takes. `efa_fit()` has no flat tuning formals left, so a bare knob
# forwarded through `...` would otherwise be dropped and the fit would quietly run the default
# preset instead of the requested one. Returns the arguments to splice into the call: the two
# controls -- built only when the caller actually named `type` or a knob -- followed by every
# remaining dot (the genuine rotation extras, e.g. `maxit`/`gam`) unchanged. One `type` governs
# both presets, exactly as the single flat `type` always did.
.repack_flat_dots <- function(dots, type = NULL) {

  # The flat interface selected the estimator with `method`; the fitting functions take
  # `estimator`. Translate the name first -- unlike the knob repacking below it must happen
  # even when a control object is passed alongside, because `method` never lives in a control.
  # Both names at once is a half-migrated call: silently letting either win would fit with
  # an estimator the caller did not (knowingly) choose, so it is rejected instead.
  if ("method" %in% names(dots)) {
    if ("estimator" %in% names(dots)) {
      cli::cli_abort(
        c("{.arg method} and {.arg estimator} cannot both be supplied.",
          "i" = "{.arg method} is the former name of {.arg estimator}; pass only one."),
        class = "efa_renamed_arg"
      )
    }
    dots$estimator <- dots$method
    dots$method <- NULL
  }

  # A control object passed explicitly is the current interface; never second-guess it.
  if (any(c("estimate_control", "rotate_control") %in% names(dots))) return(dots)

  # the former argument spellings reach the controls under their current names
  if ("P_type" %in% names(dots)) {
    dots$p_type <- dots$P_type
    dots$P_type <- NULL
  }
  if ("randomStarts" %in% names(dots)) {
    dots$random_starts <- dots$randomStarts
    dots$randomStarts <- NULL
  }

  if (is.null(type) && "type" %in% names(dots)) type <- dots$type
  dots$type <- NULL

  est_named <- intersect(names(dots), .flat_estimate_knobs)
  rot_named <- intersect(names(dots), .flat_rotate_knobs)

  # nothing to translate: hand the dots on exactly as they came in
  if (is.null(type) && length(est_named) == 0L && length(rot_named) == 0L) return(dots)

  # the preset the flat interface applied when a call tuned a knob without naming a `type`
  if (is.null(type)) type <- "EFAtools"

  # keep the leftovers by logical indexing: indexing by name would corrupt an unnamed dot
  # (its "" matches nothing, so it would come back as a NULL named NA)
  nms <- names(dots)
  if (is.null(nms)) nms <- character(length(dots))
  c(list(estimate_control = do.call(estimate_control, c(list(type = type), dots[est_named])),
         rotate_control = do.call(rotate_control, c(list(type = type), dots[rot_named]))),
    dots[!(nms %in% c(est_named, rot_named))])
}

# Render one "<label>: <value>" line inside a cli container. An unset (NA)
# preset-driven knob shows as a dim marker; other values are shown verbatim.
.control_knob_line <- function(label, value) {
  shown <- if (.is_control_unset(value)) {
    cli::col_grey("<from type preset>")
  } else {
    paste(format(value), collapse = ", ")
  }
  cli::cli_text("{.field {label}}: {shown}")
}

#' Print and format a control object
#'
#' `print()` shows the chosen `type` and each tuning knob, with an unset (`NA`)
#' preset-driven knob marked as resolved from the `type` preset. `format()`
#' assembles the same report and returns it as a character vector; `print()` is
#' `cat(format(x), sep = "\n")`. The lines follow the active console theme, so they
#' are plain when colours are disabled.
#'
#' @param x A control object from [estimate_control()] or [rotate_control()].
#' @param ... Not used; for consistency with the generic.
#'
#' @returns `print()` returns its argument `x` invisibly. `format()` returns a
#'   character vector with the report lines.
#'
#' @seealso [estimate_control()], [rotate_control()]
#'
#' @family Control functions
#'
#' @name print.efa_control
#'
#' @examples
#' est <- estimate_control(type = "SPSS")
#' est
#' writeLines(format(est))
#'
NULL

#' @rdname print.efa_control
#' @export
#' @method print efa_estimate_control
print.efa_estimate_control <- function(x, ...) {
  cat(format(x, ...), sep = "\n")
  invisible(x)
}

#' @rdname print.efa_control
#' @export
#' @method format efa_estimate_control
format.efa_estimate_control <- function(x, ...) {
  cli::cli_format_method({
    cli::cli_text("{.strong Estimation control} ({.field type}: {.val {x$type}})")
    cli::cli_text("")
    .control_knob_line("init_comm", x$init_comm)
    .control_knob_line("criterion", x$criterion)
    .control_knob_line("criterion_type", x$criterion_type)
    .control_knob_line("max_iter", x$max_iter)
    .control_knob_line("abs_eigen", x$abs_eigen)
    .control_knob_line("start_method", x$start_method)
    .control_knob_line("fiml_max_iter", x$fiml_max_iter)
    .control_knob_line("fiml_tol", x$fiml_tol)
  })
}

#' @rdname print.efa_control
#' @export
#' @method print efa_rotate_control
print.efa_rotate_control <- function(x, ...) {
  cat(format(x, ...), sep = "\n")
  invisible(x)
}

#' @rdname print.efa_control
#' @export
#' @method format efa_rotate_control
format.efa_rotate_control <- function(x, ...) {
  cli::cli_format_method({
    cli::cli_text("{.strong Rotation control} ({.field type}: {.val {x$type}})")
    cli::cli_text("")
    .control_knob_line("normalize", x$normalize)
    .control_knob_line("precision", x$precision)
    .control_knob_line("order_type", x$order_type)
    .control_knob_line("varimax_type", x$varimax_type)
    .control_knob_line("p_type", x$p_type)
    .control_knob_line("k", x$k)
    .control_knob_line("random_starts", x$random_starts)

    if (length(x$extra_args) > 0) {
      nms <- names(x$extra_args)
      if (is.null(nms)) nms <- rep("", length(x$extra_args))
      vals <- vapply(x$extra_args,
                     function(v) paste(format(v), collapse = ", "), character(1))
      # `pairs` is interpolated as a variable so any user-supplied value cannot be
      # parsed as cli markup.
      pairs <- paste0(nms, " = ", vals)
      cli::cli_text("{.field extra_args}: {pairs}")
    }
  })
}

Try the EFAtools package in your browser

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

EFAtools documentation built on Aug. 21, 2026, 5:16 p.m.