R/se-analytic.R

Defines functions .array_se_ci .boot_se_ci .apply_scaled_test .se_sandwich_unrotated .scaled_variants .scaled_chisq .se_sandwich_core .offdiag_sandwich_pieces .normal_theory_gamma .gauge_transversal .se_sandwich_constraint .se_sandwich_dispatch .se_information_rotated .wald_ci .rotation_se_method .se_information .vec_loading_labels .withhold_at_boundary .se_boundary_hint_applies .at_uniqueness_boundary .is_psd .gauge_grad .communality_se .bordered_inverse_block .compute_se_ci

# Dispatch standard-error/confidence-interval computation on the requested method. The
# nonparametric bootstrap aggregates resampled fits; the analytic methods derive SEs from
# the fitted model itself. Every branch returns the same list(SE, CI, replicates) schema, so
# the print and summary methods are agnostic to how the SEs were produced. The sandwich branch
# additionally returns a `scaled_test` element (the robust scaled chi-square), which efa_fit()
# strips off and folds into the fit indices. `gamma` (the polychoric ACOV meat) and `method`
# are used only by the sandwich. `fiml` (the two-stage EM moments + raw data) is non-NULL only
# for cor_method = "fiml", where both analytic settings route to the corrected two-stage sandwich.
.compute_se_ci <- function(fit_out, L_rot, se_method, boot_fits = NULL,
                           boot_rot = "none", ci = .95, b = NULL, N = NULL,
                           rot_info = NULL, gamma = NULL, method = NULL,
                           fiml = NULL) {

  # cor_method = "fiml": the EM correlation is a two-stage estimate, so both analytic settings
  # ("information"/"sandwich") return the corrected two-stage sandwich SE built on the saturated
  # FIML covariance. The naive Stage-2 information SE (treating the EM correlation as complete data)
  # is inconsistent under missingness and is never shipped. The bootstrap path is unchanged.
  if (!is.null(fiml) && se_method %in% c("information", "sandwich")) {
    return(.se_fiml(fit_out, rot_info, N, ci, fiml, method))
  }

  switch(se_method,
    "np-boot" = .boot_se_ci(fit_out, L_rot, boot_fits, boot_rot, ci, b),
    # rot_info is non-NULL exactly for an analytic-SE fit under a real rotation; the unrotated
    # path (rotation = "none") leaves it NULL. Both are handled inside .se_information().
    "information" = .se_information(fit_out, rot_info, N, ci, method),
    "sandwich" = .se_sandwich_dispatch(fit_out, rot_info, N, ci, gamma, method),
    NULL
  )
}


# Leading `keep`-block of the inverse of the bordered matrix [A C'; C 0] -- the constrained
# (reflexive generalised) inverse of a gauge-singular information matrix A under the identification
# constraints whose Jacobian is `Cmat` (k(k-1)/2 rows, zero rows for a single factor). Returns NULL
# if the augmented system is singular. Shared by the expected-information and sandwich SEs.
.bordered_inverse_block <- function(A, Cmat, keep) {
  nc <- nrow(Cmat)
  Aug <- if (nc > 0L) {
    rbind(cbind(A, t(Cmat)), cbind(Cmat, matrix(0, nc, nc)))
  } else {
    A
  }
  inv <- tryCatch(solve(Aug), error = function(e) NULL)
  if (is.null(inv)) return(NULL)
  inv[seq_len(keep), seq_len(keep), drop = FALSE]
}

# Delta-method SEs of the communalities h2_i = rowSums(Lambda^2)_i from a loading covariance V
# (p*k x p*k over column-major vec(Lambda)). The gradient of h2_i is 2 Lambda[i, ], nonzero only in
# variable i's loading columns. Shared by every analytic path: psi_i = 1 - h2_i is a function of
# Lambda, not a free parameter, so this single route supplies the coinciding uniqueness and
# communality SEs from whichever loading covariance the path built.
.communality_se <- function(L, V) {
  p <- nrow(L)
  k <- ncol(L)
  G_h <- matrix(0, p, p * k)
  for (i in seq_len(p)) G_h[i, (seq_len(k) - 1L) * p + i] <- 2 * L[i, ]
  sqrt(pmax(rowSums((G_h %*% V) * G_h), 0))
}

# Skeleton gradient of the rotational identification constraint for the factor pair (u, v): a
# p x k matrix with column u set to X[, v] and column v to X[, u]. The off-diagonal (u, v) entry
# of X' Lambda has exactly this gradient in vec(Lambda) -- with X = Lambda it is the gradient of
# off-diag(Lambda' Lambda), and with X = Psi^-1 Lambda the direct part of off-diag(Lambda' Psi^-1
# Lambda); the caller adds the psi(Lambda) chain-rule term. Used by `.se_sandwich_constraint()`,
# which builds the gauge constraint for every analytic path.
.gauge_grad <- function(X, u, v) {
  g <- matrix(0, nrow(X), ncol(X))
  g[, u] <- X[, v]
  g[, v] <- X[, u]
  g
}

# TRUE if M is a (numerically) positive-semidefinite covariance: finite, with a non-negative
# diagonal and no eigenvalue below a small tolerance scaled by its largest diagonal entry. Used to
# gate the analytic parameter covariances before their square roots are reported: checking only the
# diagonal would pass a covariance that is non-PSD off the diagonal (where `pmax(., 0)` would then
# silently floor a negative rotated variance to an understated SE rather than flag it).
.is_psd <- function(M) {
  if (!all(is.finite(M))) return(FALSE)
  d <- diag(M)
  if (any(d < 0)) return(FALSE)
  sym <- (M + t(M)) / 2
  ev <- tryCatch(min(eigen(sym, symmetric = TRUE, only.values = TRUE)$values),
                 error = function(e) NA_real_)
  is.finite(ev) && ev >= -1e-8 * max(d, 1)
}

# TRUE when the solution sits on the parameter-space boundary: a uniqueness at (or below) its lower
# boundary, i.e. the Heywood case `.finalize_fit()` flags. The Wald approximation every analytic
# standard error rests on is not valid there, so this is the single gate on which the analytic paths
# withhold. The ML and ULS fitters constrain the uniquenesses to [.uniqueness_floor, 1] (DWLS does
# not -- see R/DWLS.R -- and can land below zero), so an improper solution from those fitters is
# pinned AT that floor and never reaches zero: a gate keyed on `psi <= 0` would fire only for a
# hand-built covariance and never for a fitted one. Test the boundary the fitters can actually
# reach.
.at_uniqueness_boundary <- function(L) {
  psi <- 1 - rowSums(unclass(L)^2)
  anyNA(psi) || any(psi <= .uniqueness_floor + sqrt(.Machine$double.eps))
}

# The hint the analytic paths attach to `efa_se_unreliable` when the boundary above is what withheld
# the standard errors. Held next to the predicate, and shared by the rotated and unrotated message
# sites, so the wording cannot drift between them. It states a total withholding, so a caller must
# only reach for it once it has established that nothing was reported.
.se_boundary_hint <- paste0(
  "The solution is a Heywood case -- a uniqueness sits at its lower boundary -- where the Wald ",
  "approximation these standard errors rest on is not valid, so none is reported. Use ",
  "{.code se = \"np-boot\"}, or extract fewer factors."
)

# Whether that hint may be attached to a withholding -- a narrower question than the gate above,
# and the reason the two are separate predicates rather than one.
#
# The gate deliberately also fires on a non-finite solution (`anyNA(psi)`): a Wald interval is no
# more valid there, so the standard errors must go either way. But nothing is at any boundary in
# that case -- the parameter covariance is simply undefined -- so the hint's claim that the
# solution is a Heywood case would be false, and "extract fewer factors" would send the user after
# a problem the fit does not have. Those withholdings are described by the callers' general clause
# (a singular information matrix or an unusable covariance) instead, which is what they are.
# Held beside the hint so the wording and the condition it asserts cannot drift apart.
.se_boundary_hint_applies <- function(L) {
  all(is.finite(L)) && .at_uniqueness_boundary(L)
}

# Apply the boundary policy to a core's standard errors: at a solution on the parameter-space
# boundary the Wald approximation fails for every parameter however the covariance was estimated,
# so every analytic path withholds on the same test and the policy lives here rather than being
# restated at each of them. The marginals go, `reliable` goes with them (which makes the consumers
# NA-fill the persisted covariance to match), and `gauge_reliable` is RESET to TRUE because the
# gauge is not what withheld them -- the caller must not attach its gauge-specific message. What is
# deliberately NOT touched is the scaled chi-square: it is a discrepancy-function quantity rather
# than a Wald one, the boundary does not invalidate it, and for DWLS it is the only chi-square block
# the fit has. Returns the core unchanged at an interior solution.
.withhold_at_boundary <- function(core, L) {
  if (!.at_uniqueness_boundary(L)) return(core)
  core$loadings_se[] <- NA_real_
  core$uniquenesses_se[] <- NA_real_
  core$reliable <- FALSE
  core$gauge_reliable <- TRUE
  core
}

# Row/column labels for a vec(Lambda)-ordered loading covariance: "<variable>_<factor>" in the same
# column-major order the block is assembled in, so the documented ordering can be read off the
# object. NULL when the loadings carry no dimnames, in which case the block ships unlabelled rather
# than mislabelled.
.vec_loading_labels <- function(L) {
  rn <- rownames(L)
  cn <- colnames(L)
  if (is.null(rn) || is.null(cn)) return(NULL)
  as.vector(outer(rn, cn, paste, sep = "_"))
}


# Analytic expected-information SEs and Wald CIs for the ML solution, rotated or unrotated.
#
# EFAtools fits a CORRELATION structure: the analysed matrix has a diagonal fixed at exactly 1, and
# the uniquenesses psi = 1 - rowSums(Lambda^2) are a function of Lambda rather than free parameters.
# The Fisher information is therefore the one for theta = vec(Lambda) over the off-diagonal
# correlations alone,
#
#   I(theta) = Delta' Gamma^{-1} Delta,
#
# with Delta = d sigma_offdiag / d vec(Lambda) and Gamma the normal-theory asymptotic covariance of
# the sample correlations evaluated at the model-implied Sigma (Cudeck, 1989; Ogasawara, 1998).
# Assembling the covariance-structure information over (vec(Lambda), psi) instead would attribute
# Wishart sampling variability to a diagonal that carries none, and leak it into the loading block.
#
# I(theta) is exactly the Godambe sandwich of `.se_sandwich_core()` at the optimal (efficient)
# weight V = Gamma^{-1}, where the meat Delta' V Gamma V Delta collapses back onto the bread
# Delta' V Delta. Routing through the sandwich therefore reuses the model Jacobian, the gauge
# constraint, the bordered inverse, the PSD gate and the rotation propagation rather than
# duplicating them. No scaled chi-square is requested: the normal-theory weight is the one the ML
# discrepancy already minimises, so the ordinary chi-square stands.
#
# Cost: Gamma is n x n over the n = p(p-1)/2 pairs and is inverted once, so this is O(n^3) rather
# than the O((p k + p)^3) of a covariance-structure assembly -- a one-shot 0.1 s at p = 18 and under
# a second at p = 40, far below a bootstrap.
.se_information <- function(fit_out, rot_info, N, ci, method) {

  L <- unclass(fit_out$unrot_loadings)

  # At a boundary solution (`.at_uniqueness_boundary()`) the Wald intervals this path reports are
  # not valid, so withhold the covariance: the core then NA-fills through its usual unusable-Gamma
  # branch and the shared efa_se_unreliable warning fires, rather than reporting a boundary standard
  # error. Short-circuiting here also skips assembling and inverting an n x n Gamma the fit would
  # never use; `.se_sandwich_dispatch()` applies the same gate for the paths that still need the
  # core (it builds the scaled chi-square, which stays reportable at a boundary fit).
  Gamma <- if (.at_uniqueness_boundary(L)) {
    NULL
  } else {
    # Model-implied correlation matrix (unit diagonal by construction).
    Sigma <- tcrossprod(L)
    diag(Sigma) <- 1
    # `.se_sandwich_core()` takes Gamma on the variance scale Var(rho-hat) and restores the unit
    # scale itself, so undo the unit scaling `.normal_theory_gamma()` returns.
    .normal_theory_gamma(Sigma, utils::combn(nrow(L), 2L)) / N
  }

  .se_sandwich_dispatch(fit_out, rot_info, N, ci, Gamma, method,
                        optimal_weight = TRUE, scaled = FALSE)
}


# Map a rotation name to the warm-start criterion family and tuning argument used by the compiled
# `.rotation_se_jacobian()`, mirroring the engine selection in `.orth_engines`/`.oblq_engines`
# (R/rotate_model.R). varimax shares the Crawford-Ferguson criterion at kappa = 1 / p, quartimax at
# kappa = 0, equamax at kappa = k / (2 p); quartimin is oblimin at gam = 0. The geomin offset
# `delta` and oblimin `gam` are taken from the resolved criterion arguments (the same `.gpf_crit`
# defaults the rotation itself used). promax and simplimax have no usable analytic Jacobian and are
# rejected before reaching here, so an unrecognised rotation returns NULL.
.rotation_se_method <- function(rotation, p, k, crit_args = list()) {
  gam <- if (is.null(crit_args$gam)) 0 else crit_args$gam
  delta <- if (is.null(crit_args$delta)) 0.01 else crit_args$delta
  switch(rotation,
    varimax   = list(method = "cf",       param = 1 / p,       oblique = FALSE),
    quartimax = list(method = "cf",       param = 0,           oblique = FALSE),
    equamax   = list(method = "cf",       param = k / (2 * p), oblique = FALSE),
    bentlerT  = list(method = "bentler",  param = 0,           oblique = FALSE),
    geominT   = list(method = "geomin",   param = delta,       oblique = FALSE),
    bifactorT = list(method = "bifactor", param = 0,           oblique = FALSE),
    oblimin   = list(method = "oblimin",  param = gam,         oblique = TRUE),
    quartimin = list(method = "oblimin",  param = 0,           oblique = TRUE),
    bentlerQ  = list(method = "bentler",  param = 0,           oblique = TRUE),
    geominQ   = list(method = "geomin",   param = delta,       oblique = TRUE),
    bifactorQ = list(method = "bifactor", param = 0,           oblique = TRUE),
    NULL)
}

# Wald lower/upper interval around an estimate from its standard error. `est` is unclassed so the
# returned bounds are plain numeric matrices/vectors (the rotated estimates carry the "LOADINGS"
# class).
.wald_ci <- function(est, se, z) {
  est <- unclass(est)
  list(lower = est - z * se, upper = est + z * se)
}


# Analytic standard errors and Wald CIs for an obliquely or orthogonally ROTATED solution. The
# caller's covariance of the unrotated loadings (`se0`) is propagated through the rotation by the
# delta method; every analytic path -- expected information, robust sandwich, and the corrected
# two-stage FIML sandwich -- supplies its own, so the propagation is written once. The rotation
# maps the unrotated loadings A
# to the rotated pattern L = A T(A)^{-1 T} (oblique) / A T(A) (orthogonal) and, for oblique
# rotations, the factor correlations Phi = T(A)' T(A); both depend on A alone (not the
# uniquenesses), so only the p*k loading block V of the parameter covariance is needed. The
# rotation Jacobians d vec(L) / d vec(A) and d vec(Phi) / d vec(A) are obtained by
# finite-differencing a warm-started re-rotation of A (`.rotation_se_jacobian()`), which re-solves
# the rotation from the converged transformation so the optimum tracks each perturbation smoothly.
# This is the delta-method standard error of Jennrich (1973), matching lavaan's rotation.se =
# "delta".
# Because rotated quantities are identification-invariant they are comparable across packages
# (unlike the unrotated loadings, whose orientation is identification-dependent).
#
#   Var(vec L)   = J_L V J_L'                                   -> rotated-loading SEs
#   Var(vec Phi) = J_Phi V J_Phi'                              -> factor-correlation SEs (oblique)
#   S = L Phi:  J_S = (Phi' (x) I_p) J_L + (I_k (x) L) J_Phi;  Var(vec S) = J_S V J_S'  (oblique)
#   h2_i = rowSums(A^2)_i = 1 - psi_i (rotation-invariant); SE(h2_i) by the delta method on V
#
# Fills the same SE/CI schema as the bootstrap (rot_loadings/Phi/Structure plus the unrotated
# loadings and uniquenesses); there are no replicate arrays. Falls back to NA rotated SEs (with a
# classed warning) at a Heywood case, a singular information matrix, or a rotation the warm start
# cannot reproduce (e.g. a non-converged transformation). References: Jennrich (1973); Zhang &
# Preacher (2015).
.se_information_rotated <- function(fit_out, rot_info, N, ci, se0) {

  A <- unclass(fit_out$unrot_loadings)
  p <- nrow(A)
  k <- ncol(A)
  pk <- p * k
  psi <- 1 - rowSums(A^2)
  z <- stats::qnorm(1 - (1 - ci) / 2)

  # Unrotated pieces (always reported, mirroring the bootstrap's unrot_loadings) and the loading
  # covariance block propagated through the rotation. Each analytic path supplies its own covariance
  # in `se0` (uniform $vcov/$loadings_se/$uniquenesses_se schema), so the propagation below is
  # identical for all of them and is purely a function of the loading covariance V.
  V <- se0$vcov[seq_len(pk), seq_len(pk), drop = FALSE]
  SE_unrot <- se0$loadings_se
  SE_psi <- se0$uniquenesses_se
  dimnames(SE_unrot) <- dimnames(A)
  names(SE_psi) <- rownames(A)
  # The caller NA's the unrotated SEs whenever the parameter covariance is non-finite OR carries a
  # negative variance on its diagonal (a near-degenerate orientation `solve()` still inverts). Reuse
  # that as the single reliability signal for the rotated quantities too: they are propagated from
  # the same covariance, so if it was too ill-conditioned for the unrotated SEs it must not yield
  # finite rotated SEs either. NA the covariance here, before anything reads it: on the non-PSD path
  # the caller ships a FINITE covariance next to NA marginal SEs, and every quantity derived from it
  # below (the communality delta method, the persisted slot) must fail closed with them rather than
  # report a standard error the caller already judged untrustworthy.
  info_reliable <- !anyNA(SE_unrot)
  if (!info_reliable) V[] <- NA_real_

  # A degenerate rotational gauge is a strictly weaker signal: it diverges only along the gauge
  # directions, which the rotation Jacobian annihilates, so the rotated loadings, Phi, the structure
  # coefficients and the communalities all stay valid and V must NOT be NA'd here. Withhold the
  # unrotated loadings alone -- and only after `info_reliable` has been read, so a degenerate gauge
  # cannot masquerade as an unusable covariance and take the rotated quantities down with it.
  # Every analytic path supplies the flag (they share the gauge constraint it describes); absent
  # rather than FALSE would mean no diagnostic was available, which is then not treated as one.
  gauge_degenerate <- isFALSE(se0$gauge_reliable)
  if (gauge_degenerate) SE_unrot[] <- NA_real_

  spec <- .rotation_se_method(rot_info$rotation, p, k, rot_info$crit_args)
  rotmat <- rot_info$rotmat
  L_rot <- unclass(rot_info$rot_loadings)
  Phi_pt <- rot_info$Phi
  # Orthogonal rotations and the single-factor fall-back carry no factor correlations, so they
  # report rotated loadings and communalities only.
  oblique <- !is.null(Phi_pt)

  # The communalities are the exact complement of the uniquenesses (h2_i = rowSums(A^2)_i =
  # 1 - psi_i = (L Phi L')_ii), so h2_i and psi_i are one estimand up to sign and must share a
  # standard error. Both are the ordinary delta method on the loading covariance V through the
  # gradient 2 A[i, ] of h2_i (`.communality_se()`); because every path's `se0$uniquenesses_se`
  # comes from that same call on that same V, the two agree exactly.
  h2 <- rowSums(A^2)
  names(h2) <- rownames(A)
  SE_h2 <- .communality_se(A, V)
  names(SE_h2) <- rownames(A)

  na_mat <- function() {
    m <- matrix(NA_real_, p, k)
    dimnames(m) <- dimnames(L_rot)
    m
  }
  SE_rot <- na_mat()
  SE_Phi <- if (oblique) {
    matrix(NA_real_, k, k, dimnames = dimnames(Phi_pt))
  } else {
    NULL
  }
  SE_S <- if (oblique) na_mat() else NULL
  S_pt <- if (oblique) `dimnames<-`(L_rot %*% Phi_pt, dimnames(L_rot)) else NULL

  can_rotate <- !is.null(spec) && is.matrix(rotmat) && !anyNA(rotmat) &&
    k >= 2L && !is.null(L_rot) && info_reliable

  if (can_rotate) {
    normalize <- isTRUE(rot_info$normalize)
    # The bifactor criterion exempts a fixed column as the general factor, but `.reflect_and_order`
    # may have moved the general factor out of the first column. It is the one column that loads on
    # every variable -- so the column whose smallest absolute loading is largest, the others being
    # group factors with near-zero out-group loadings. Identify it so the re-rotation optimises the
    # same criterion the point estimate did; the other criteria ignore general_col.
    general_col <- if (identical(spec$method, "bifactor")) {
      which.max(apply(abs(L_rot), 2, min)) - 1L
    } else {
      0L
    }
    # Forward difference of the warm-started re-rotation (the stencil lavaan's delta method uses);
    # the whole p*k finite-difference loop runs in compiled code (.rotation_se_jacobian()) to avoid
    # p*k round trips to R. This is a one-shot cost per fit: a few hundredths of a second for a
    # typical problem (p = 18, k = 3) and about 1 s for a large one (p = 40, k = 8) -- far below a
    # bootstrap, and an order of magnitude faster than the equivalent pure-R numeric differentiation.
    eps <- 1e-4
    jac <- .rotation_se_jacobian(A, rotmat, spec$method, spec$param, normalize,
                                 spec$oblique, eps, general_col)
    # The re-rotation at the unperturbed A must reproduce the reported rotated loadings; a gross
    # mismatch flags a criterion/parameter mismatch or a transformation outside the criterion's
    # basin, for which the Jacobian is not trustworthy. The tolerance absorbs the small drift
    # between the reported optimum and a re-solve to a tighter tolerance -- and, for varimax, between
    # the SVD/SPSS varimax algorithm and the Crawford-Ferguson re-rotation at a flat optimum -- while
    # still catching a genuine basin/criterion mismatch (which moves loadings an order of magnitude
    # more).
    if (isTRUE(jac$valid) && max(abs(jac$base_loadings - L_rot)) < 5e-2) {
      J_L <- jac$J_L
      SE_rot <- matrix(sqrt(pmax(rowSums((J_L %*% V) * J_L), 0)), p, k)
      dimnames(SE_rot) <- dimnames(L_rot)
      if (spec$oblique) {
        J_Phi <- jac$J_Phi
        SE_Phi <- matrix(sqrt(pmax(rowSums((J_Phi %*% V) * J_Phi), 0)), k, k)
        diag(SE_Phi) <- 0       # the unit diagonal of Phi is fixed, so it has no variance
        SE_Phi <- (SE_Phi + t(SE_Phi)) / 2
        dimnames(SE_Phi) <- dimnames(Phi_pt)
        # Structure S = L Phi: dvec(S) = (Phi' (x) I_p) dvec(L) + (I_k (x) L) dvec(Phi); the
        # rotation matrices are the re-rotated point estimates, consistent with the Jacobians.
        L0 <- jac$base_loadings
        Phi0 <- jac$base_Phi
        J_S <- kronecker(t(Phi0), diag(p)) %*% J_L + kronecker(diag(k), L0) %*% J_Phi
        SE_S <- matrix(sqrt(pmax(rowSums((J_S %*% V) * J_S), 0)), p, k)
        dimnames(SE_S) <- dimnames(L_rot)
      }
    }
  } else if (info_reliable && k < 2L) {
    # No rotation was actually performed (single factor): the rotated loadings equal the unrotated
    # ones, so their SEs are the unrotated loading SEs; there is no Phi or structure matrix.
    SE_rot <- SE_unrot
    dimnames(SE_rot) <- dimnames(L_rot)
  }

  SE <- list(unrot_loadings = SE_unrot, uniquenesses = SE_psi,
             rot_loadings = SE_rot, communalities = SE_h2)
  CI <- list(unrot_loadings = .wald_ci(A, SE_unrot, z),
             uniquenesses = .wald_ci(psi, SE_psi, z),
             rot_loadings = .wald_ci(L_rot, SE_rot, z),
             communalities = .wald_ci(h2, SE_h2, z))
  if (oblique) {
    SE$Phi <- SE_Phi
    SE$Structure <- SE_S
    CI$Phi <- .wald_ci(Phi_pt, SE_Phi, z)
    CI$Structure <- .wald_ci(S_pt, SE_S, z)
  }

  if (anyNA(SE_unrot) || anyNA(SE_psi) || anyNA(SE_rot) || anyNA(SE_h2) ||
      (oblique && (anyNA(SE_Phi) || anyNA(SE_S)))) {
    cli::cli_warn(
      c("Analytic standard errors could not be computed for all parameters.",
        "i" = if (gauge_degenerate && info_reliable) {
          "The factor solution's rotational orientation is only weakly determined (two canonical variances nearly coincide), so the unrotated loadings have no well-defined standard error. The rotated loadings and communalities are gauge-invariant and unaffected."
        } else if (!info_reliable && .se_boundary_hint_applies(A)) {
          # Named rather than listed among the possible causes: every analytic path
          # (`.se_sandwich_dispatch()` and `.se_fiml()`) withholds through `.withhold_at_boundary()`,
          # so where the hint applies it can be reported as a fact about the solution. Two things
          # make that attribution safe. `!info_reliable`: a fit that reached the boundary but still
          # produced finite unrotated SEs would contradict the hint's claim that nothing was
          # reported. And `.se_boundary_hint_applies()` rather than the gate itself, which also
          # fires on a non-finite solution that is at no boundary at all. Both fall to the last
          # clause instead.
          .se_boundary_hint
        } else {
          "This occurs when the parameter covariance is singular, or when the rotation could not be reproduced for the standard-error Jacobian."
        }),
      class = "efa_se_unreliable"
    )
  }

  # Surface the pk x pk unrotated loading vcov (whichever analytic covariance `se0` carried) so it
  # can be persisted on the EFA object alongside the rotated SEs. It was already NA-filled above
  # when the unrotated SEs were unreliable, so the slot is consistent with the marginal SEs and
  # downstream consumers can fail closed on `anyNA()`. A degenerate gauge deliberately does NOT
  # NA-fill it: the covariance is finite, PSD and correct, and only its gauge-dependent marginals
  # are unusable (see the matching note in `.se_sandwich_unrotated()`).
  lab <- .vec_loading_labels(A)
  if (!is.null(lab)) dimnames(V) <- list(lab, lab)

  list(SE = SE, CI = CI, replicates = NULL, vcov_unrot_loadings = V)
}


# Robust (sandwich) standard errors and a scaled chi-square for the ordinal/polychoric path.
# The factor model is fitted by minimising a weighted off-diagonal discrepancy
# F(theta) = (s - sigma(theta))' V (s - sigma(theta)) over the free loadings theta = vec(Lambda),
# where s = vech of the off-diagonal sample (polychoric/tetrachoric) correlations,
# sigma_ij(theta) = (Lambda Lambda')_ij, and V is the estimator's weight matrix (DWLS: diagonal
# inverse asymptotic variances; ULS: identity; ML: the normal-theory GLS weight). When V is not
# the inverse of the true asymptotic covariance of s (which it never is for ordinal data), the
# naive inverse-information SEs are biased, and the Godambe sandwich
#   Var(theta) = (1/(N-1)) A^- (Delta' V Gamma V Delta) A^-,   A = Delta' V Delta,
# with Gamma the (threshold-adjusted) asymptotic covariance of s, gives consistent SEs (Browne,
# 1984; Muthen, 1984; Satorra & Bentler, 1994). This mirrors lavaan's se = "robust.sem". The
# rotational over-parameterisation of A is handled by bordering it with the identification-
# constraint Jacobian, exactly as the expected-information path does. The bordered loading
# covariance is propagated through a rotation by the same Jacobian machinery as the information
# SEs (.se_information_rotated), so promax/simplimax stay bootstrap-only.
.se_sandwich_dispatch <- function(fit_out, rot_info, N, ci, Gamma, method,
                                  optimal_weight = FALSE, scaled = TRUE) {

  core <- .se_sandwich_core(fit_out, N, Gamma, method, optimal_weight, scaled)

  # The gate sits here rather than inside the core because the core's own `psi <= 0` test asks a
  # different question -- whether Psi^-1 exists for the gauge constraint -- and must not be moved to
  # the floor (see the note there); and because the core's scaled chi-square is deliberately kept
  # (see `.withhold_at_boundary()`, which owns the policy for every analytic path).
  core <- .withhold_at_boundary(core, fit_out$unrot_loadings)

  res <- if (is.null(rot_info)) {
    .se_sandwich_unrotated(fit_out, core, ci)
  } else {
    # Reuse the information-SE rotation propagation verbatim by supplying the robust loading
    # covariance in place of the expected-information one (identical $vcov schema). Because the
    # rotation Jacobian annihilates the rotational gauge directions, the rotated SEs are
    # identification-invariant and do not depend on which bordering constraint produced V_AA.
    se0 <- list(vcov = core$V_AA,
                loadings_se = core$loadings_se,
                uniquenesses_se = core$uniquenesses_se,
                gauge_reliable = core$gauge_reliable)
    .se_information_rotated(fit_out, rot_info, N, ci, se0 = se0)
  }

  res$scaled_test <- core$scaled_test
  res
}

# Identification-constraint Jacobian for the rotational gauge: the gradient, with respect to the
# column-major vec(Lambda), of the off-diagonal entries the estimator's canonical orientation sets
# to zero. nc = k(k-1)/2 rows, p*k columns; for k = 1 there is no gauge freedom and the matrix has
# zero rows. The constraint must match the orientation the loadings are reported in so the unrotated
# loading SEs are scaled in that gauge (the rotated SEs are gauge-invariant and the same for any
# transversal choice). With `psi = NULL` (eigen-based ULS/DWLS) it fixes off-diag(Lambda'Lambda);
# with `psi` supplied (ML, where psi = 1 - rowSums(Lambda^2)) it fixes off-diag(Lambda' Psi^-1
# Lambda) (Lawley & Maxwell), whose gradient carries a chain-rule term through psi(Lambda).
.se_sandwich_constraint <- function(L, psi = NULL) {
  p <- nrow(L)
  k <- ncol(L)
  nc <- k * (k - 1L) / 2L
  Cmat <- matrix(0, nc, p * k)
  if (k > 1L) {
    Astar <- if (is.null(psi)) NULL else L / psi   # Psi^-1 Lambda for the ML gauge
    uv <- 0L
    for (v in 2:k) {
      for (u in seq_len(v - 1L)) {
        uv <- uv + 1L
        grad <- if (is.null(psi)) {
          .gauge_grad(L, u, v)
        } else {
          # d off-diag(Lambda' Psi^-1 Lambda)_uv / d vec(Lambda): the direct part plus the term
          # from psi_a = 1 - rowSums(Lambda^2)_a depending on Lambda (d psi_a / d lambda_ac = -2
          # lambda_ac), which contributes 2 (Astar[, u] Astar[, v]) Lambda across all columns.
          .gauge_grad(Astar, u, v) + 2 * (Astar[, u] * Astar[, v]) * L
        }
        Cmat[uv, ] <- as.vector(grad)
      }
    }
  }
  Cmat
}

# Smallest gauge-transversal conditioning a solution can have before its rotational orientation is
# treated as degenerate. The quantity `.gauge_transversal()` returns is a cosine in [0, 1] -- the
# smallest principal angle between the gauge-fixing constraint's row space and the gauge directions
# it has to pin down -- so this is a dimensionless angle, not a parameter scale. Blow-ups in the
# reported standard errors set in below about 0.05 and well-determined solutions sit above 0.2, so
# the floor flags only a clearly degenerate orientation.
.gauge_transversal_floor <- 0.05

# Conditioning of the gauge-fixing constraint as a transversal to the rotational gauge orbit.
#
# The bordered inverse of `A` under the constraint `Cmat` is the constrained (reflexive generalised)
# inverse (Rao & Mitra, 1971, ch. 3; Silvey, 1959)
#   A^- = (I - Z (C Z)^-1 C) A^+ (I - C' (C Z)^-1' Z'),
# where Z spans null(A). The whole conditioning of the augmented system therefore sits in the
# k(k-1)/2 x k(k-1)/2 matrix C Z, and the reported unrotated loading covariance is amplified by
# ||(C Z)^-1||. The null space is available in closed form: the gauge directions are dLambda =
# Lambda S for antisymmetric S, since d(Lambda Lambda') = Lambda (S + S') Lambda' = 0, so no
# eigendecomposition of A is needed.
#
# C Z degenerates when the canonical orientation is not pinned down. Dropping the chain-rule term,
# C Z is diagonal with entries (d_u - d_v) for d = diag(Lambda' Psi^-1 Lambda): when two of the
# canonical variances that identify the ML solution collide (Lawley & Maxwell, 1971, sec. 2.3), the
# orientation within that two-plane is arbitrary and the unrotated loadings have no well-defined
# limiting distribution -- the classic rotational indeterminacy of the unrotated factor solution.
# The divergence is genuine, not a numerical artefact -- but it is confined to the gauge, so it must
# be reported rather than reported around: the rotated loadings, Phi and the structure coefficients
# are gauge-invariant (the rotation Jacobian annihilates these directions) and the communalities
# rowSums(Lambda^2) are likewise invariant, so all of them stay valid and are left untouched by the
# caller.
#
# Returns the smallest singular value of the row-normalised constraint restricted to the gauge
# directions -- dimensionless and in [0, 1] -- or Inf when there is no gauge freedom to fix (k = 1).
.gauge_transversal <- function(L, Cmat) {

  k <- ncol(L)
  if (nrow(Cmat) == 0L || k < 2L) return(Inf)
  if (!all(is.finite(L)) || !all(is.finite(Cmat))) return(0)

  # Z = [vec(L S_uv)] over the antisymmetric basis, in the same (u, v) order as the constraint rows.
  # L %*% S_uv is just two sign-flipped column copies (column u becomes -L[, v], column v becomes
  # L[, u]), so fill it directly rather than forming S and multiplying.
  Z <- matrix(0, length(L), nrow(Cmat))
  col <- 0L
  for (v in 2:k) {
    for (u in seq_len(v - 1L)) {
      col <- col + 1L
      Zc <- matrix(0, nrow(L), k)
      Zc[, u] <- -L[, v]
      Zc[, v] <- L[, u]
      Z[, col] <- as.vector(Zc)
    }
  }
  # A rank-deficient Z means the loadings themselves are collinear across factors, so the gauge is
  # not even a well-defined orbit; treat it as maximally degenerate.
  qrZ <- qr(Z)
  if (qrZ$rank < ncol(Z)) return(0)
  Z <- qr.Q(qrZ)

  # Normalise the constraint rows so the singular values measure orientation only: the constraint is
  # a set of equations fixed at zero, so its overall row scaling is arbitrary and must not enter.
  rn <- sqrt(rowSums(Cmat^2))
  if (any(rn <= 0)) return(0)

  min(svd((Cmat / rn) %*% Z)$d)
}

# Normal-theory asymptotic covariance of the off-diagonal Pearson correlations, on the unit scale
# (N * Cov(r-hat)) and in the `pairs` (utils::combn(p, 2)) order the sandwich machinery uses
# throughout. `Sigma` is the correlation matrix it is evaluated at -- the model-implied one for the
# expected-information SEs.
#
# For pairs (i, j) and (k, l) (Pearson & Filon, 1898; Olkin & Siotani, 1976; Steiger, 1980):
#   N Cov(r_ij, r_kl) = 1/2 rho_ij rho_kl (rho_ik^2 + rho_il^2 + rho_jk^2 + rho_jl^2)
#                       + rho_ik rho_jl + rho_il rho_jk
#                       - rho_ij (rho_ik rho_il + rho_jk rho_jl)
#                       - rho_kl (rho_ik rho_jk + rho_il rho_jl),
# which reduces to the familiar (1 - rho^2)^2 on the diagonal. Assembled by whole-matrix indexing
# (`Sigma[ii, ii]` and friends are already n x n in pair order) rather than looping over the n^2
# pair combinations; the result is symmetric analytically and is symmetrised against rounding.
.normal_theory_gamma <- function(Sigma, pairs) {

  ii <- pairs[1, ]
  jj <- pairs[2, ]
  n <- ncol(pairs)

  # Drop the variable dimnames while sub-setting: `Sigma[ii, ii]` would otherwise label the pair
  # axis with repeated variable names ("V1", "V1", ...), which mislabels a pair-indexed matrix and
  # would propagate into the covariance built from it.
  Sigma <- unname(Sigma)
  r_ij <- Sigma[cbind(ii, jj)]
  Rik <- Sigma[ii, ii, drop = FALSE]
  Ril <- Sigma[ii, jj, drop = FALSE]
  Rjk <- Sigma[jj, ii, drop = FALSE]
  Rjl <- Sigma[jj, jj, drop = FALSE]

  # rho_ij indexes the row pair, rho_kl the column pair: `r_ij * M` scales rows (column-major
  # recycling) and `rep(r_ij, each = n) * M` scales columns.
  G <- 0.5 * outer(r_ij, r_ij) * (Rik^2 + Ril^2 + Rjk^2 + Rjl^2) +
    Rik * Rjl + Ril * Rjk -
    r_ij * (Rik * Ril + Rjk * Rjl) -
    rep(r_ij, each = n) * (Rik * Rjk + Ril * Rjl)

  (G + t(G)) / 2
}

# Shared geometry of every off-diagonal sandwich: the estimator's Stage-2 weight V (held as a
# diagonal `vdiag` or a full `Vmat`, the other NULL), the weighted model Jacobian VD, the bordered
# (constrained generalised) inverse of the bread A = Delta' V Delta, and the conditioning of the
# gauge constraint that bordering used. The polychoric/ADF sandwich, the expected-information path
# (`.se_sandwich_core()`) and the corrected two-stage FIML sandwich (`.se_fiml_core()`,
# `.fiml_scaled_test()`) differ only in the meat they sandwich between these pieces, so they share
# them rather than each carrying a copy -- which is what let the gauge choice and the gauge
# diagnostic drift apart between the paths before.
#
# `Gamma` is the asymptotic covariance on the UNIT scale (N * Var(rho-hat)) and is read only by the
# two weights that need it: DWLS's diagonal inverse variances, and the efficient weight
# `optimal_weight = TRUE` (V = Gamma^-1, at which the sandwich collapses onto its bread and returns
# the Fisher information of the correlation structure). The ML/ULS paths -- including both FIML
# ones -- pass `Gamma = NULL`. Returns NULL when the model-implied correlation, the efficient
# weight, or the bordered system is singular, or when a weight that needs `Gamma` was asked for
# without one; every caller NA-fills on that.
#
# `gauge = FALSE` skips the transversal diagnostic for a caller that does not read it (the
# corrected two-stage chi-square, which runs once per bootstrap replicate and is gauge-invariant),
# so that caller does not pay for a QR and an SVD it discards.
.offdiag_sandwich_pieces <- function(L, method, Gamma = NULL, optimal_weight = FALSE,
                                     gauge = TRUE) {

  # The DWLS and efficient weights are built FROM `Gamma`, so neither is defined without one.
  # `.se_sandwich_core()` screens that out before calling, but the FIML callers pass `Gamma = NULL`
  # with a `method` their own callers guarantee is ML or ULS: fail closed here rather than let an
  # unguarded combination surface as a non-conformable-arguments error further down.
  if ((optimal_weight || identical(method, "DWLS")) && is.null(Gamma)) return(NULL)

  L <- unclass(L)
  p <- nrow(L)
  k <- ncol(L)
  pk <- p * k

  pairs <- utils::combn(p, 2L)
  pi <- pairs[1, ]
  pj <- pairs[2, ]
  n <- ncol(pairs)

  # Model Jacobian Delta = d sigma_offdiag / d vec(Lambda) (n x pk). For pair (i, j),
  # d sigma_ij / d Lambda[a, f] = (a == i) Lambda[j, f] + (a == j) Lambda[i, f]; rows in
  # utils::combn(p, 2) order, columns in column-major vec(Lambda) order.
  Delta <- matrix(0, n, pk)
  for (f in seq_len(k)) {
    Delta[cbind(seq_len(n), (f - 1L) * p + pi)] <- L[pj, f]
    Delta[cbind(seq_len(n), (f - 1L) * p + pj)] <- L[pi, f]
  }

  # Estimator weight V (unit scale). DWLS: diagonal inverse variances; ULS: identity; ML: the
  # normal-theory GLS weight 1/2 (Sigma^-1 (x) Sigma^-1) restricted to the off-diagonal pairs, at
  # the model-implied correlation matrix Sigma = Lambda Lambda' (unit diagonal).
  if (optimal_weight) {
    # Efficient weight V = Gamma^-1. The meat Delta' V Gamma V Delta then equals the bread
    # Delta' V Delta = Delta' Gamma^-1 Delta, so V_AA reduces to the (bordered) inverse of the
    # correlation-structure Fisher information.
    Vmat <- tryCatch(solve(Gamma), error = function(e) NULL)
    if (is.null(Vmat)) return(NULL)
    Vmat <- (Vmat + t(Vmat)) / 2
    vdiag <- NULL
  } else if (method == "DWLS") {
    vdiag <- 1 / diag(Gamma)
    Vmat <- NULL
  } else if (method == "ULS") {
    vdiag <- rep(1, n)
    Vmat <- NULL
  } else {
    Sigma <- tcrossprod(L)
    diag(Sigma) <- 1
    P <- tryCatch(solve(Sigma), error = function(e) NULL)
    if (is.null(P)) return(NULL)
    vdiag <- NULL
    Vmat <- matrix(0, n, n)
    for (s in seq_len(n)) {
      a <- pi[s]
      b <- pj[s]
      Vmat[, s] <- 0.5 * (P[pi, a] * P[pj, b] + P[pi, b] * P[pj, a])
    }
  }
  is_diag <- is.null(Vmat)

  # VD = V Delta; bread A = Delta' V Delta (singular by the k(k-1)/2 rotational gauge freedoms).
  VD <- if (is_diag) vdiag * Delta else Vmat %*% Delta
  A <- crossprod(Delta, VD)

  # Border A with the gauge-fixing constraint so the augmented system is invertible; the leading
  # pk-block of its inverse is the reflexive generalised inverse of A. The constraint fixes the
  # rotational orientation, so it must match the one the solution's loadings are reported in: the
  # unrotated loading SEs are scaled in that gauge (the rotated SEs and scaled chi-square are
  # gauge-invariant, so the choice does not affect them). Rather than assume the orientation from the
  # estimator label, detect it from the loadings: an eigen-based ULS/DWLS solution is
  # Lambda'Lambda-diagonal, an ML solution is Lambda'Psi^-1 Lambda-diagonal (Lawley & Maxwell). The
  # orientation the solution is in leaves a (scale-free) relative off-diagonal at the optimiser floor
  # while the other is O(0.01-1), so the smaller ratio identifies the gauge -- and detecting keeps it
  # tied to the actual solution, so a future estimator with either identification is handled without
  # special-casing. Two cases the loadings cannot resolve fall back to a fixed choice: the
  # Lambda'Psi^-1 Lambda gauge needs Psi^-1, so it is unavailable where that is undefined (psi <= 0)
  # or at a non-finite solution -- routed to Lambda'Lambda; and homogeneous uniquenesses (Psi
  # proportional to I) make BOTH orientations diagonal, so the gauge is undetermined by the loadings
  # (yet the two still give different SEs through the chain-rule term), broken by the estimator's
  # identification. A single factor has no rotational freedom (the constraint is empty either way).
  #
  # The test here is psi <= 0 and deliberately NOT the `.uniqueness_floor` boundary that the callers
  # treat as a Heywood case. The two ask different questions: that one asks whether the solution sits
  # on the parameter-space boundary (where a Wald interval is invalid), this one only whether Psi^-1
  # exists. At a uniqueness pinned at the floor Psi^-1 is large but perfectly well defined, and the
  # solution is still oriented Lambda'Psi^-1 Lambda-diagonal -- so forcing the Lambda'Lambda
  # constraint there would mismatch the orientation the loadings are reported in and hand
  # `.gauge_transversal()` a near-tangent transversal (0.002 against 0.16 for the matching constraint
  # on a boundary ML fit), withholding standard errors that are fine.
  psi <- 1 - rowSums(L^2)
  use_ltpil_gauge <- if (k < 2L || anyNA(psi) || any(psi <= 0)) {
    FALSE
  } else {
    rel_off <- function(M) max(abs(M[upper.tri(M)])) / max(abs(diag(M)), .Machine$double.eps)
    r_ltl <- rel_off(crossprod(L))
    r_ltpil <- rel_off(crossprod(L, L / psi))
    # Both orientations near-diagonal (Psi proportional to I): the loadings cannot distinguish the
    # gauge, so use the estimator's known identification; otherwise the smaller ratio wins.
    if (max(r_ltl, r_ltpil) < 1e-4) method == "ML" else r_ltpil < r_ltl
  }
  Cmat <- if (use_ltpil_gauge) {
    .se_sandwich_constraint(L, psi = psi)
  } else {
    .se_sandwich_constraint(L)
  }
  Abread <- .bordered_inverse_block(A, Cmat, pk)
  if (is.null(Abread)) return(NULL)

  # Conditioning of that constraint as a transversal to the gauge orbit. A near-degenerate
  # transversal amplifies the unrotated loading covariance by ||(C Z)^-1|| without making it
  # non-finite or non-PSD, so it passes the callers' `.is_psd()` gate and would otherwise ship a
  # silently meaningless standard error. Reported separately from a caller's `reliable` flag because
  # the divergence lives purely in the gauge: everything gauge-invariant derived from the covariance
  # stays valid. Computed after the bordered inverse so an exactly singular augmented system -- which
  # returns NULL above and discards this flag -- does not pay for the decomposition, and skipped
  # entirely for a caller that does not read it.
  gauge_reliable <- if (gauge) {
    .gauge_transversal(L, Cmat) >= .gauge_transversal_floor
  } else {
    NULL
  }

  list(vdiag = vdiag, Vmat = Vmat, VD = VD, Abread = Abread, pairs = pairs,
       gauge_reliable = gauge_reliable)
}

# Build the robust loading covariance V_AA (p*k x p*k), the unrotated loading/uniqueness SEs, and
# the scaled chi-square from the fitted loadings and the polychoric ACOV `Gamma`. `Gamma` enters on
# the variance scale Var(rho-hat) and is converted here to the unit asymptotic-variance scale
# (N * Var) used by the WLS/sandwich formulas (= lavaan's NACOV). Returns NA SEs (reliable = FALSE)
# at a singular bordered information matrix or an unusable covariance.
#
# The linear algebra is O(n^2 q) for the meat and O(n^3) for the chi-square trace (n = p(p-1)/2
# pairs, q = p*k), and is kept in R: it is a one-shot cost of a few hundredths of a second for a
# typical problem and about 0.3 s (p = 30) to 1.5 s (p = 40, n = 780 pairs) for a large one --
# negligible next to the polychoric estimate it builds on, and far below a bootstrap.
#
# `optimal_weight = TRUE` replaces the estimator weight with V = Gamma^-1, at which the sandwich
# collapses onto its bread and returns the Fisher information of the correlation structure (the
# se = "information" path; see `.se_information()`). `scaled = FALSE` skips the scaled chi-square,
# which only the robust paths report.
.se_sandwich_core <- function(fit_out, N, Gamma, method,
                              optimal_weight = FALSE, scaled = TRUE) {

  L <- unclass(fit_out$unrot_loadings)
  p <- nrow(L)
  k <- ncol(L)
  pk <- p * k

  # `gauge_reliable = TRUE` on the NA path: everything is already withheld, and the gauge is not
  # what withheld it, so the caller's gauge-specific message must not be attached to this branch.
  na_core <- list(
    V_AA = matrix(NA_real_, pk, pk),
    loadings_se = matrix(NA_real_, p, k),
    uniquenesses_se = rep(NA_real_, p),
    scaled_test = NULL,
    reliable = FALSE,
    gauge_reliable = TRUE
  )

  if (is.null(Gamma) || anyNA(Gamma) || nrow(Gamma) != p * (p - 1L) / 2L) {
    return(na_core)
  }

  # Variance scale (Var(rho-hat)) -> unit asymptotic-variance scale (N * Var); the 1/(N-1) on the
  # final covariance and the N on the chi-square then follow lavaan's robust.sem conventions.
  Gamma <- N * Gamma

  # Model Jacobian, estimator weight, bordered bread, and gauge conditioning; shared verbatim with
  # the corrected two-stage FIML sandwich, which supplies a different meat below.
  pieces <- .offdiag_sandwich_pieces(L, method, Gamma = Gamma,
                                     optimal_weight = optimal_weight)
  if (is.null(pieces)) return(na_core)
  VD <- pieces$VD
  Abread <- pieces$Abread

  # Meat Delta' V Gamma V Delta = (V Delta)' Gamma (V Delta); robust covariance A^- meat A^- /(N-1).
  Gamma_theta <- crossprod(VD, Gamma %*% VD)
  V_AA <- (Abread %*% Gamma_theta %*% Abread) / (N - 1)

  # The covariance must be positive semidefinite: a non-finite entry or a negative eigenvalue (even
  # with a still-positive diagonal) would otherwise corrupt the marginal, uniqueness, and rotated
  # SEs, which read the full V_AA.
  reliable <- .is_psd(V_AA)
  loadings_se <- if (reliable) sqrt(diag(V_AA)) else rep(NA_real_, pk)

  # Uniqueness SE = communality SE (psi_i = 1 - rowSums(Lambda^2)_i), via the shared gradient.
  uniq_se <- if (!reliable) rep(NA_real_, p) else .communality_se(L, V_AA)

  scaled_test <- if (!reliable || !scaled) NULL else {
    .scaled_chisq(fit_out, Gamma, pieces$pairs, VD, pieces$vdiag, pieces$Vmat, Abread, N)
  }

  list(
    V_AA = V_AA,
    loadings_se = matrix(loadings_se, p, k),
    uniquenesses_se = uniq_se,
    scaled_test = scaled_test,
    reliable = reliable,
    gauge_reliable = pieces$gauge_reliable
  )
}

# Scaled chi-square test statistics (Satorra & Bentler, 1994; Asparouhov & Muthen, 2010) for the
# weighted off-diagonal fit. T = N * (s - sigma)' V (s - sigma) is the (unscaled) fit statistic;
# U = V - V Delta A^- Delta' V is the residual projector; c1 = tr(U Gamma), c2 = tr((U Gamma)^2)
# its trace coefficients (Gamma on the unit scale). The projector spans only the off-diagonal
# correlation residuals, so this is the two-stage correlation-structure correction (Browne, 1984)
# and is not identical to the full WLSMV statistic of lavaan/Mplus, which also projects the
# thresholds. Returns the scaled-shifted, mean-adjusted, and mean-and-variance-adjusted statistics
# plus the scaled baseline statistic for the robust CFI/TLI/RMSEA. NULL when the model is
# just-identified (df <= 0) or the traces degenerate.
.scaled_chisq <- function(fit_out, Gamma, pairs, VD, vdiag, Vmat, Abread, N) {

  L <- unclass(fit_out$unrot_loadings)
  df <- fit_out$fit_indices$df
  if (is.null(df) || is.na(df) || df <= 0) return(NULL)

  n <- ncol(pairs)
  is_diag <- is.null(Vmat)
  apply_V <- function(x) if (is_diag) vdiag * x else as.vector(Vmat %*% x)

  # The sample correlations s and residuals e are read from the (possibly smoothed) analysis matrix,
  # while Gamma is the asymptotic covariance of the observed un-projected polychoric correlations --
  # the same convention the DWLS point estimate and lavaan use (the weights/ACOV describe the
  # observed correlations even when the matrix is projected to positive definiteness).
  R <- unclass(fit_out$orig_R)
  s <- R[t(pairs)]                       # off-diagonal sample correlations
  e <- (R - tcrossprod(L))[t(pairs)]     # residuals r_ij - (Lambda Lambda')_ij

  Tstat <- N * sum(e * apply_V(e))

  # Residual projector U = V - V Delta A^- Delta' V. The dense V Delta A^- Delta' V term makes U
  # dense anyway, so add the (diagonal or full) weight onto it in place instead of materialising a
  # dense V.
  U <- -(VD %*% Abread %*% t(VD))
  if (is_diag) diag(U) <- diag(U) + vdiag else U <- U + Vmat
  c1 <- sum(U * Gamma)
  UG <- U %*% Gamma
  c2 <- sum(UG * t(UG))
  if (!is.finite(c1) || !is.finite(c2) || c1 <= 0 || c2 <= 0) return(NULL)

  # Independence baseline (all off-diagonal correlations fixed to 0): residual is s, projector the
  # baseline weight V0. V0 is diagonal for every estimator -- it equals the (model-independent)
  # weight for DWLS/ULS, and 1/2 I for ML (the normal-theory weight at Sigma = I) -- so the baseline
  # traces use only its diagonal.
  v0 <- if (is_diag) vdiag else rep(0.5, n)
  Tbase <- N * sum(s * (v0 * s))
  df_base <- n
  c1_base <- sum(v0 * diag(Gamma))       # tr(diag(v0) Gamma)
  V0G <- v0 * Gamma                      # diag(v0) %*% Gamma (row scaling)
  c2_base <- sum(V0G * t(V0G))           # tr((diag(v0) Gamma)^2)

  scaled <- .scaled_variants(Tstat, df, c1, c2)
  chi_null <- if (is.finite(c1_base) && is.finite(c2_base) && c2_base > 0) {
    .scaled_variants(Tbase, df_base, c1_base, c2_base)$T_ss
  } else {
    NA_real_
  }

  list(
    m = nrow(L),
    df = df,
    chi = scaled$T_ss,                   # scaled-shifted (WLSMV default) -> the reported chi-square
    chi_scaling = scaled$a,
    chi_shift = scaled$b,
    chi_unscaled = Tstat,
    chi_mean_adjusted = scaled$T_mean,
    chi_mean_var = scaled$T_mv,
    df_mean_var = scaled$df_mv,
    chi_null = chi_null,
    df_null = df_base
  )
}

# The three scalings of a fit statistic T (Satorra & Bentler, 1994; Asparouhov & Muthen, 2010)
# from its trace coefficients c1 = tr(U Gamma), c2 = tr((U Gamma)^2) and degrees of freedom df:
# mean-adjusted (T * df/c1, reference df); scaled-shifted a*T + b with a = sqrt(df/c2),
# b = df - a*c1 (reference df, the WLSMV default); mean-and-variance-adjusted T * c1/c2 with the
# Satterthwaite df* = c1^2/c2.
.scaled_variants <- function(Tstat, df, c1, c2) {
  a <- sqrt(df / c2)
  list(
    T_mean = Tstat * df / c1,
    a = a,
    b = df - a * c1,
    T_ss = a * Tstat + (df - a * c1),
    df_mv = c1^2 / c2,
    T_mv = Tstat * c1 / c2
  )
}

# Unrotated analytic SE/CI wrapper: fills the uniform list(SE, CI, replicates) schema from the
# core's loading/uniqueness SEs. Shared by every analytic path -- expected information, robust
# sandwich, and the corrected two-stage FIML sandwich -- which differ only in the covariance the
# core built (`.se_information_rotated()` plays the same role under a rotation).
.se_sandwich_unrotated <- function(fit_out, core, ci) {

  L <- unclass(fit_out$unrot_loadings)
  SE_L <- core$loadings_se
  SE_psi <- core$uniquenesses_se
  dimnames(SE_L) <- dimnames(L)
  names(SE_psi) <- rownames(L)

  # A degenerate rotational gauge inflates the unrotated loading SEs without bound while leaving
  # every gauge-invariant quantity intact, so withhold the unrotated loadings alone and keep the
  # communalities and uniquenesses, which are invariant under Lambda -> Lambda T and unaffected.
  gauge_degenerate <- isFALSE(core$gauge_reliable)
  if (gauge_degenerate) SE_L[] <- NA_real_

  if (anyNA(SE_L) || anyNA(SE_psi)) {
    cli::cli_warn(
      c("Analytic standard errors could not be computed for all parameters.",
        "i" = if (gauge_degenerate) {
          "The factor solution's rotational orientation is only weakly determined (two canonical variances nearly coincide), so the unrotated loadings have no well-defined standard error. The communalities and uniquenesses are unaffected; apply a rotation, or use {.code se = \"np-boot\"}, for loading-level uncertainty."
        } else if (anyNA(SE_L) && .se_boundary_hint_applies(L)) {
          # As in `.se_information_rotated()`: the hint claims a total withholding, so it is only
          # attached once the loading SEs are actually gone. They and the uniqueness SEs are
          # all-or-nothing together today, but a path that withheld only one of them would
          # otherwise be described by a hint that its own output contradicts. And it claims a
          # Heywood case, so a non-finite solution -- which the gate withholds on too, at no
          # boundary -- takes the general clause below rather than this one.
          .se_boundary_hint
        } else {
          "This occurs when the bordered information matrix is singular, or when the asymptotic covariance is not usable."
        }),
      class = "efa_se_unreliable"
    )
  }

  # h2_i = 1 - psi_i exactly, so the communality is the uniqueness up to sign and shares its
  # standard error verbatim (the delta method the core already ran through the gradient
  # 2 L[i, ]). Report it here too, so an unrotated fit does not force the user to know the
  # identity to obtain communality intervals.
  h2 <- rowSums(L^2)
  names(h2) <- rownames(L)
  psi <- 1 - h2
  SE_h2 <- SE_psi
  z <- stats::qnorm(1 - (1 - ci) / 2)

  # `.se_sandwich_core()` always returns a numeric V_AA, even when `reliable = FALSE` (it is
  # only the marginal `loadings_se` that gets NA-filled in the unreliable branch). NA-fill the
  # persisted covariance to match, so a finite-but-not-PSD V_AA does not silently ship next to
  # NA SEs and propagate sqrt(NaN) into downstream pooling.
  # A degenerate gauge does NOT NA-fill the persisted block. The documented contract for that slot
  # is "NA-filled if the analytic covariance is unreliable (a Heywood case or a singular bordered
  # information matrix)", and a weakly determined orientation is neither: the covariance is finite,
  # PSD and -- verified against the closed-form reflexive inverse -- correct. What is unusable is
  # reading its diagonal as the standard error of a parameter the gauge does not pin down. Keeping
  # it finite also leaves consumers that pool in a COMMON gauge across fits (`efa_mi()`) able to
  # recover the gauge-invariant quantities, which a wholesale NA fill would take down with it.
  V_AA <- core$V_AA
  if (!isTRUE(core$reliable)) V_AA[] <- NA_real_
  lab <- .vec_loading_labels(L)
  if (!is.null(lab)) dimnames(V_AA) <- list(lab, lab)

  list(
    SE = list(unrot_loadings = SE_L, uniquenesses = SE_psi,
              communalities = SE_h2),
    CI = list(unrot_loadings = .wald_ci(L, SE_L, z),
              uniquenesses = .wald_ci(psi, SE_psi, z),
              communalities = .wald_ci(h2, SE_h2, z)),
    replicates = NULL,
    vcov_unrot_loadings = V_AA
  )
}

# Fold the robust scaled chi-square into the fit indices, overwriting the chi-square-derived block
# (.gof() leaves it undefined for DWLS and reports the non-robust discrepancy for ML/ULS). The
# reported chi-square is the scaled-shifted (WLSMV-default) statistic; the mean-adjusted and
# mean-and-variance-adjusted statistics and the scaling/shift are added as extra fields. CFI/TLI/
# RMSEA come from the scaled model and baseline statistics via the shared .chi_fit_indices().
# `ci` is the caller's own "solve the analytic RMSEA bounds" flag, threaded through rather than
# fixed: `.gof()` runs this on every cor_method = "fiml" fit, including the bootstrap replicates it
# calls with ci = FALSE, whose bounds are discarded by `.boot_se_ci()` anyway.
.apply_scaled_test <- function(fit_indices, st, N, ci = TRUE) {

  if (is.null(st)) return(fit_indices)

  # The scaled (sandwich) model and baseline statistics are already on a comparable scale, so they
  # serve directly as the CFI/TLI/RMSEA noncentrality inputs (chi_cfi / chi_null_cfi).
  idx <- .chi_fit_indices(st$chi, st$df, st$chi_null, st$df_null, N, st$m, ci = ci,
                          chi_cfi = st$chi, chi_null_cfi = st$chi_null)

  fit_indices$chi <- st$chi
  fit_indices$df <- st$df
  fit_indices$p_chi <- idx$p_chi
  fit_indices$CFI <- idx$CFI
  fit_indices$TLI <- idx$TLI
  fit_indices$RMSEA <- idx$RMSEA
  fit_indices$RMSEA_LB <- idx$RMSEA_LB
  fit_indices$RMSEA_UB <- idx$RMSEA_UB
  # AIC/BIC/ECVI are likelihood-ratio chi-square information criteria; they have no standard
  # interpretation when built on the moment-scaled (Satorra-Bentler) statistic, so leave them NA
  # rather than report a misleading model-comparison number.
  fit_indices$AIC <- NA_real_
  fit_indices$BIC <- NA_real_
  fit_indices$ECVI <- NA_real_
  fit_indices$chi_null <- st$chi_null
  fit_indices$df_null <- st$df_null
  fit_indices$p_null <- idx$p_null

  fit_indices$chi_scaled_type <- "scaled.shifted"
  fit_indices$chi_scaling <- st$chi_scaling
  fit_indices$chi_shift <- st$chi_shift
  fit_indices$chi_unscaled <- st$chi_unscaled
  fit_indices$chi_mean_adjusted <- st$chi_mean_adjusted
  fit_indices$chi_mean_var <- st$chi_mean_var
  fit_indices$df_mean_var <- st$df_mean_var

  fit_indices
}


.boot_se_ci <- function(fit_target, L_rot, boot_fit, boot_rot, ci, b) {

  l_ci <- (1 - ci) / 2
  ps <- c(l_ci, ci + l_ci)

  ### calculate stats for unrot loadings and gof measures
  L_unrot <- fit_target$unrot_loadings

  ncol_L <- ncol(L_unrot)
  nrow_L <- nrow(L_unrot)
  colnam_L <- colnames(L_unrot)
  rownam_L <- rownames(L_unrot)

  L_unrot_boot <- array(NA_real_, c(nrow_L, ncol_L, b),
                        dimnames = list(rownam_L, colnam_L,
                                        NULL))
  # The bootstrap aggregates only the numeric fit indices: a scaled chi-square fit (the
  # cor_method = "fiml" two-stage statistic, or any se = "sandwich" fit) adds the character
  # `chi_scaled_type` tag and the extra scaled-statistic components, which the quantile/SD
  # aggregation below cannot consume. Aligning each replicate to these names also lets a
  # replicate whose scaled statistic degenerated (and so lacks a component) contribute NA
  # there rather than shifting every column.
  gof_names <- names(fit_target$fit_indices)[
    vapply(fit_target$fit_indices, is.numeric, logical(1))]
  # The RMSEA confidence bounds are dropped: the replicate fits are run with ci = FALSE, so
  # they are NA in every replicate, and a bootstrap standard error of an interval bound is
  # not a meaningful quantity in the first place.
  gof_names <- setdiff(gof_names, c("RMSEA_LB", "RMSEA_UB"))
  gof_boot <- matrix(NA_real_, ncol = length(gof_names), nrow = b,
                     dimnames = list(NULL, gof_names))
  residuals_boot <- array(NA_real_, c(nrow_L, nrow_L, b),
                          dimnames = list(rownam_L, rownam_L,
                                          NULL))

  # Track replicates that could not be fit (NULL) or aligned, so they are
  # excluded from the bootstrap statistics rather than aborting the whole call.
  failed <- vapply(boot_fit, is.null, logical(1))

  for (boot_i in seq_len(b)) {

    if (failed[boot_i]) next

    # save aligned loading matrix
    aligned <- tryCatch(
      .align_solution(L_unrot, boot_fit[[boot_i]]$unrot_loadings),
      error = function(e) NULL
    )
    if (is.null(aligned)) {
      failed[boot_i] <- TRUE
      next
    }

    L_unrot_boot[,, boot_i] <- aligned$loadings
    fi_i <- boot_fit[[boot_i]]$fit_indices
    gof_boot[boot_i, ] <- vapply(gof_names, function(nm) {
      v <- fi_i[[nm]]
      if (is.null(v) || !is.numeric(v)) NA_real_ else as.numeric(v[1L])
    }, numeric(1))
    residuals_boot[,, boot_i] <- boot_fit[[boot_i]]$residuals

  }

  n_failed <- sum(failed)
  # Reported on the object so the effective B behind the standard errors stays recoverable
  # from a saved fit, not only from the warning below.
  valid_reps <- b - n_failed
  if (n_failed == b) {
    cli::cli_abort(
      c("All {b} bootstrap replicates failed; no bootstrap standard errors could be computed.",
        "i" = "The resampled correlation matrices may be degenerate; try more observations or fewer factors."),
      class = "efa_boot_all_failed"
    )
  }
  if (n_failed > 0) {
    cli::cli_warn(
      c("{n_failed} bootstrap replicate{?s} failed and {?was/were} excluded.",
        "i" = "Bootstrap standard errors and confidence intervals are based on {b - n_failed} replicate{?s}."),
      class = "efa_boot_replicate_failed"
    )
  }
  # A bootstrap standard error is the dispersion across replicates, so fewer than two leaves it
  # undefined (`sd()` of a single value is NA) and collapses the percentile interval onto that one
  # replicate. `b_boot` is bounded below at 2, so this is reachable only when replicates fail at
  # run time -- including the extreme case where all but one of a large `b_boot` do.
  if (valid_reps < 2L) {
    cli::cli_warn(
      c("Bootstrap standard errors are not defined from {valid_reps} usable replicate{?s}.",
        "i" = "The standard errors are {.val {NA}} and the confidence bounds collapse onto the single replicate; raise {.arg b_boot}, or check why the replicate fits failed."),
      class = "efa_se_unreliable"
    )
  }

  # se = sd of bootstrap replications (Zhang, 2014, Estimating Standard Errors
  # in Exploratory Factor Analysis)
  L_unrot_se_ci <- .array_se_ci(L_unrot_boot, ps)
  gof_se_ci <- .array_se_ci(gof_boot, ps, M = 2)
  residuals_se_ci <- .array_se_ci(residuals_boot, ps)
  names(gof_se_ci$se) <- gof_names
  names(gof_se_ci$ci$lower) <- gof_names
  names(gof_se_ci$ci$upper) <- gof_names

  if(boot_rot == "oblique") {

    colnam_L <- colnames(L_rot)

    L_rot_boot <- array(NA_real_, c(nrow_L, ncol_L, b),
                        dimnames = list(rownam_L, colnam_L,
                                        NULL))
    Phi_rot_boot <- array(NA_real_, c(ncol_L, ncol_L, b),
                          dimnames = list(colnam_L, colnam_L,
                                          NULL))
    Structure_boot <- array(NA_real_, c(nrow_L, ncol_L, b),
                        dimnames = list(rownam_L, colnam_L,
                                        NULL))

    failed_rot <- 0

    # Align every successfully fit replicate to the target in a single compiled
    # call over the loading cube, rather than one efa_procrustes() round trip per
    # replicate. Replicates that failed to fit or could not be aligned to the
    # point estimate are excluded from the cube and stay NA in the output arrays.
    keep <- which(!failed)
    if (length(keep) > 0) {
      A_cube <- array(NA_real_, c(nrow_L, ncol_L, length(keep)))
      for (j in seq_along(keep)) {
        A_cube[, , j] <- boot_fit[[keep[j]]]$unrot_loadings
      }

      aligned <- .oblique_procrustes_batch(A_cube, L_rot, random_starts = 5)

      for (j in seq_along(keep)) {
        boot_i <- keep[j]
        # Exclude a replicate only when no valid alignment could be produced. The
        # best multi-start fit is kept even if it did not formally converge: it is
        # the lowest-objective alignment available and its loadings are well-defined.
        if (!isTRUE(aligned$valid[j])) {
          failed_rot <- failed_rot + 1
          next
        }
        L_j <- matrix(aligned$loadings[, , j], nrow_L, ncol_L)
        Phi_j <- matrix(aligned$Phi[, , j], ncol_L, ncol_L)
        L_rot_boot[, , boot_i] <- L_j
        Phi_rot_boot[, , boot_i] <- Phi_j
        Structure_boot[, , boot_i] <- L_j %*% Phi_j
      }
    }

    valid_rot <- b - n_failed - failed_rot
    if (failed_rot > 0) {
      cli::cli_warn(c("{failed_rot} target rotation{?s} in the bootstrap procedure could not be aligned.",
                    "i" = "Bootstrap SE and CI of rotated loadings, factor correlations and structure coefficients are based on {valid_rot} bootstrap sample{?s}."),
                    class = "efa_boot_rotation_failed")
    }

    L_rot_se_ci <- .array_se_ci(L_rot_boot, ps)
    Phi_rot_se_ci <- .array_se_ci(Phi_rot_boot, ps)
    Structure_se_ci <- .array_se_ci(Structure_boot, ps)

    out <- list(
      SE = list(
        unrot_loadings = L_unrot_se_ci$se,
        rot_loadings = L_rot_se_ci$se,
        Phi = Phi_rot_se_ci$se,
        Structure = Structure_se_ci$se,
        fit_indices = gof_se_ci$se,
        residuals = residuals_se_ci$se,
        valid_replicates = valid_reps,
        valid_target_rotations = valid_rot
      ),
      CI = list(
        unrot_loadings = L_unrot_se_ci$ci,
        rot_loadings = L_rot_se_ci$ci,
        Phi = Phi_rot_se_ci$ci,
        Structure = Structure_se_ci$ci,
        fit_indices = gof_se_ci$ci,
        residuals = residuals_se_ci$ci
      ),
      replicates = list(
        unrot_loadings = L_unrot_boot,
        rot_loadings = L_rot_boot,
        Phi = Phi_rot_boot,
        Structure = Structure_boot,
        fit_indices = gof_boot,
        residuals = residuals_boot
      )
    )

  } else if (boot_rot == "orthogonal") {
    colnam_L <- colnames(L_rot)

    L_rot_boot <- array(NA_real_, c(nrow_L, ncol_L, b),
                        dimnames = list(rownam_L, colnam_L,
                                        NULL))

    failed_rot <- 0

    for (boot_i in seq_len(b)) {

      if (failed[boot_i]) next

      # save target-rotated loading matrix
      aligned_i <- tryCatch(
        efa_procrustes(boot_fit[[boot_i]]$unrot_loadings,
                       Target = L_rot, rotation = "orthogonal"),
        error = function(e) NULL
      )
      if (is.null(aligned_i)) {
        failed_rot <- failed_rot + 1
        next
      }
      L_rot_boot[,, boot_i] <- aligned_i$loadings
    }

    valid_rot <- b - n_failed - failed_rot
    if (failed_rot > 0) {
      cli::cli_warn(c("{failed_rot} target rotation{?s} in the bootstrap procedure could not be aligned.",
                    "i" = "Bootstrap SE and CI of rotated loadings are based on {valid_rot} bootstrap sample{?s}."),
                    class = "efa_boot_rotation_failed")
    }

    L_rot_se_ci <- .array_se_ci(L_rot_boot, ps)

    out <- list(
      SE = list(
        unrot_loadings = L_unrot_se_ci$se,
        rot_loadings = L_rot_se_ci$se,
        fit_indices = gof_se_ci$se,
        residuals = residuals_se_ci$se,
        valid_replicates = valid_reps,
        valid_target_rotations = valid_rot
      ),
      CI = list(
        unrot_loadings = L_unrot_se_ci$ci,
        rot_loadings = L_rot_se_ci$ci,
        fit_indices = gof_se_ci$ci,
        residuals = residuals_se_ci$ci
      ),
      replicates = list(
        unrot_loadings = L_unrot_boot,
        rot_loadings = L_rot_boot,
        fit_indices = gof_boot,
        residuals = residuals_boot
      )
    )


  } else {

    out <- list(
      SE = list(
        unrot_loadings = L_unrot_se_ci$se,
        fit_indices = gof_se_ci$se,
        residuals = residuals_se_ci$se,
        valid_replicates = valid_reps
      ),
      CI = list(
        unrot_loadings = L_unrot_se_ci$ci,
        fit_indices = gof_se_ci$ci,
        residuals = residuals_se_ci$ci
      ),
      replicates = list(
        unrot_loadings = L_unrot_boot,
        fit_indices = gof_boot,
        residuals = residuals_boot
      )
    )

  }


  out

}

# Bootstrap standard deviation and percentile interval of a replicate array, over the margins `M`
# (the replicate index is whichever dimension is left over).
#
# Both interval bounds come from ONE sweep. `stats::quantile()` sorts each cell's length-B
# replicate vector, so requesting the two probabilities in separate sweeps sorts every cell twice;
# asking for both at once halves the sorting and the traversals. `apply()` puts the two results on
# a new LEADING dimension, so `q` is length(probs) x dim(x)[M] whatever `M` is; flattening it to a
# 2 x length(se) matrix indexes that dimension without having to know the rank. `se` carries the
# target shape and dimnames and the bounds are reshaped onto it, reproducing what a per-probability
# `apply()` returned for every rank this is called with (a vector for a matrix input, a matrix for
# a 3-D cube, a 3-D array for the group bootstrap's 4-D cube). Quantile type (the default 7) and
# `na.rm` are unchanged, so the bounds are identical to sweeping twice.
.array_se_ci <- function(x, probs, M = c(1, 2)) {
  se <- apply(x, M, stats::sd, na.rm = TRUE)
  q <- apply(x, M, stats::quantile, probs = probs, na.rm = TRUE, names = FALSE)
  dim(q) <- c(length(probs), length(se))

  bound <- function(i) {
    b <- q[i, ]
    if (is.null(dim(se))) {
      names(b) <- names(se)
    } else {
      dim(b) <- dim(se)
      dimnames(b) <- dimnames(se)
    }
    b
  }

  list(
    se = se,
    ci = list(lower = bound(1L), upper = bound(2L))
  )
}

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.