Nothing
#' Exploratory factor analysis on multiple data imputations
#'
#' @author Andreas Soteriades, Markus Steiner
#'
#' @description
#' Fits [efa_fit()] to each of several imputed datasets, aligns the
#' factor solutions to a common factor space, and pools the resulting estimates
#' and selected fit quantities across imputations.
#'
#' @details
#' `efa_mi()` is the multiple-imputation route to handling missing data:
#' several imputed datasets are each fitted with [efa_fit()] and the solutions pooled.
#' A single-fit alternative is full-information maximum likelihood, available
#' directly in [efa_fit()] as `cor_method = "fiml"`, which EM-estimates a two-stage
#' correlation from one raw dataset with missing values. Both feed the same
#' correlation-scale EFA core and differ only in how the missingness is handled;
#' FIML is intentionally not routed through `efa_mi()`, which is a multi-fit
#' pooler by construction.
#'
#' Both routes assume the values are missing at random (MAR). Which one to prefer
#' is largely practical: FIML is a single, efficient fit and is the simpler
#' default when the analysis model is the whole story, whereas multiple imputation
#' is more flexible when the imputation model should draw on auxiliary variables
#' not in the factor model, or when the same imputations feed several downstream
#' analyses.
#'
#' ## Standard-error pooling routes
#'
#' The pooling pathway is selected automatically from the `se` method recorded on
#' the component [efa_fit()] fits, which must be identical across imputations:
#'
#' - `se = "none"`: no standard errors are pooled.
#' - `se = "information"`: the per-imputation expected-information standard errors
#' are pooled with Rubin's (1987) rules (Wald intervals).
#' - `se = "sandwich"`: the two-stage pooled-inputs (MI2S) approach fits a single
#' model on the Rubin-pooled correlation matrix and asymptotic covariance.
#' - `se = "np-boot"`: the non-parametric bootstrap replicates are re-aligned to
#' the multiple-imputation target and Rubin-pooled.
#'
#' On the information and np-boot routes, if pooled standard errors cannot be
#' produced (for example an unreliable analytic covariance or too few bootstrap
#' replicates) the pool falls back to point-estimate-only pooling and downgrades
#' `settings$se` to `"none"`. The MI2S route is the exception: its single fit
#' fuses the point estimates and standard errors through the pooled asymptotic
#' covariance, so a structural failure aborts directly rather than falling back.
#'
#' ## Aligning solutions across imputations
#'
#' The same [efa_fit()] model is fitted to each imputed dataset and the solutions are
#' put into a common factor space before averaging. For oblique solutions the
#' factor intercorrelations are aligned together with the loadings so the model
#' stays internally consistent.
#'
#' `target_method` controls how rotated solutions are aligned. `"first_target"`
#' (the default) aligns every imputation to the first imputation's rotated
#' solution by one Procrustes rotation each. `"consensus"` instead refines a
#' centroid target by Generalized Procrustes Analysis (Gower 1975; van Ginkel &
#' Kroonenberg 2014; Lorenzo-Seva & Van Ginkel 2016), starting from the *medoid*
#' imputation's rotated solution -- the one closest in aligned squared distance to
#' all the others. `"consensus"` is supported for orthogonal rotations only.
#'
#' Factor loadings are only unique up to rotation; "gauge" here means which
#' particular rotation, or orientation, a solution is expressed in. The two
#' methods differ in the rotational gauge the pooled solution ends up in, and so
#' in how it responds to the order of `data_list`. The GPA iteration moves its
#' target toward the centroid but keeps the gauge of the solution it started from,
#' so starting it at the medoid -- a property of the set, not of the list order --
#' makes the pooled rotated solution invariant to that order, as the pooled
#' unrotated solution already is. `"first_target"` anchors on the first imputation
#' by construction, so an atypical first imputation fixes the orientation for
#' every other one. Permuting `data_list` therefore moves the pooled pattern --
#' by a few hundredths of a loading unit when imputations are similar, more when
#' they disagree. For oblique rotations, the factor correlations move with it.
#' Where the two anchors coincide -- and, more generally,
#' where the imputations agree -- the two methods give effectively the same pooled
#' estimate and `"consensus"` is simply the more expensive; where they do not, the
#' pooled patterns differ by the rotation between the two gauges. Passing `start`
#' through `consensus_args` overrides the medoid anchor and makes the consensus
#' order dependent again.
#'
#' `align_unrotated` controls how unrotated loadings are aligned before pooling:
#' `"signed_tucker_congruence"` (the default) matches them up to factor reordering
#' and sign changes, `"procrustes"` aligns them to the first imputation by
#' orthogonal Procrustes rotation, and `"none"` averages them as returned by
#' [efa_fit()].
#'
#' The default anchors the matching on the *medoid* imputation (defined above)
#' rather than on whichever imputation happens to come first, so the pooled
#' *unrotated* solution does not depend on the order of `data_list`. The rotated
#' solution is aligned separately, against a reference chosen by `target_method`,
#' and still depends on that reference.
#'
#' The pooled unrotated matrix is then returned in the same gauge as a single
#' [efa_fit()] fit. Its identifying constraint differs by extraction method -- a
#' principal-axis extraction and a maximum-likelihood extraction fix the
#' rotation differently (Anderson & Rubin 1956; Lawley & Maxwell 1971) -- and is
#' read off each component fit automatically, so the pooled matrix can be
#' compared element-by-element with an [efa_fit()] solution. A solution that
#' meets neither constraint -- an improper one, say -- is left as aligned. The
#' correction is a common orthogonal rotation, so communalities, the total
#' variance accounted for, the model-implied correlation matrix, the residuals,
#' and RMSR are unchanged; only the split of variance across factors moves.
#' `"procrustes"` and `"none"` keep their first-imputation anchor and are
#' returned as aligned.
#'
#' ## Pooling point estimates
#'
#' Point estimates are pooled by arithmetic averaging after alignment. For oblique
#' rotations the structure matrix is recomputed from the pooled pattern matrix and
#' pooled factor correlations, \eqn{Structure = \Lambda \Phi}, and communalities
#' are the diagonal of the reproduced correlation matrix,
#' \eqn{diag(\Lambda \Phi \Lambda')} for oblique rotations and
#' \eqn{diag(\Lambda \Lambda')} otherwise. Residuals are not averaged across
#' imputations; they are the pooled observed correlation matrix minus the
#' model-implied correlation of the pooled solution, so RMSR/SRMR are based on
#' these pooled residuals. Both are returned, though the print and summary methods
#' show SRMR only.
#'
#' ## Pooling the model chi-square and fit indices
#'
#' The model chi-square and the indices derived from it (ECVI and the descriptive
#' AIC/BIC) are pooled with the D2 rule (Li, Meng, Raghunathan &
#' Rubin, 1991), not arithmetically averaged. RMSEA is pooled by the same rule but
#' from a second D2 pool of the per-imputation discrepancies taken on the
#' uncorrected \eqn{N - 1} scale. The printed RMSEA therefore does not reconcile
#' by hand with the printed chi-square; the statistic it is formed from is
#' `chi_cfi` in `mi_diagnostics`. Because D2 shrinks the pooled chi-square in
#' proportion to the between-imputation variability, the pooled RMSEA can fall
#' below the mean of the per-imputation RMSEAs; read it together with the
#' per-imputation fit. The incremental indices CFI (Bentler, 1990) and TLI
#' (Tucker & Lewis, 1973) are instead the average of the per-imputation indices,
#' which keeps them consistent with the component fits and avoids out-of-range
#' values; the separately pooled model and baseline chi-squares those indices
#' would be formed from remain available in `mi_diagnostics`.
#' AIC and BIC, if returned, are chi-square-derived descriptive quantities and are
#' not likelihood-based MI information criteria. They are reported only where the
#' component fits report them: whenever a component withholds them -- any
#' `cor_method = "fiml"` fit, and any fit whose chi-square is a scaled statistic,
#' such as `se = "sandwich"` -- the pooled AIC, BIC, and ECVI are `NA` too, matching
#' what [efa_fit()] returns for a single such fit. On the sandwich/MI2S route the
#' chi-square is the single fit's scaled statistic rather than a D2 pool.
#'
#' ## Bootstrap pooling (np-boot)
#'
#' If each component [efa_fit()] call was run with `se = "np-boot"`, pooled bootstrap SEs
#' and Wald-type MI confidence intervals are computed for loadings, communalities,
#' residuals, and, when applicable, factor correlations and structure
#' coefficients. The unrotated bootstrap replicates are re-aligned to the final MI
#' target before the within-imputation covariance is estimated, and Rubin pooling
#' is applied with \eqn{T = Ubar + (1 + 1/m) B}. The confidence level of the pooled
#' intervals is set by `p`, not by the component [efa_fit()] calls' `ci`.
#'
#' ## Analytic pooling (information)
#'
#' With `se = "information"`, the analytic unrotated-loading and uniqueness SEs
#' returned by each fit are pooled element-wise with Rubin's rules
#' (\eqn{T = Ubar + (1 + 1/m) B}), with Wald intervals on the plain Rubin (1987)
#' degrees of freedom (the analytic loadings are asymptotically normal, so the
#' Barnard-Rubin (1999) adjustment reduces to this form). NA
#' propagation is fail-closed: if any imputation is NA at an element, all pooled
#' outputs for that element are NA. When a rotation was requested, the rotated
#' loadings, communalities, and (for oblique rotations) factor correlations and
#' structure coefficients are pooled as well; residual SE pooling is available only
#' on the bootstrap path. Under `align_unrotated = "procrustes"` the full unrotated
#' covariance `vcov_unrot_loadings` (populated by `se = "information"`) is
#' propagated through the alignment, so it must be present and reliable on every
#' fit. The default alignment also mixes loading columns, through the common
#' canonical-gauge rotation, and so propagates the same covariance; where a fit
#' does not carry it, the unrotated standard errors are returned as `NA` rather
#' than aborting, and the remaining families still pool.
#'
#' A rotated-loading standard error is conditional on the rotation criterion
#' used (see References). For both orthogonal and oblique rotations the
#' within-imputation variance is therefore each fit's own
#' criterion-aware delta-method rotated SE (the quantity `efa_fit()` returns), reused
#' after a signed-permutation alignment to the MI target, and the
#' between-imputation variance is the sample variance of the aligned rotated
#' loadings. This is a deliberate approximation -- each SE is conditional on its
#' own fit's rotation optimum rather than on a common gauge -- and is flagged by
#' `MI$<param>$method = "signed_permutation_approx"`. Communalities are
#' rotation-invariant and pool element-wise. For a fully gauge-consistent rotated
#' uncertainty, cross-check with `se = "np-boot"`.
#'
#' ## Two-stage pooling (sandwich / MI2S)
#'
#' With `se = "sandwich"` (robust SEs from a polychoric/tetrachoric or
#' continuous-Pearson asymptotic covariance), pooling follows the two-stage,
#' pooled-inputs approach (Chung & Cai 2019; Sriutaisuk, Liu, Chung, Kim & Gu
#' 2025): the correlation matrix and the asymptotic covariance of its off-diagonal
#' entries are Rubin-pooled across imputations, and a single `EFA` model is
#' fitted to the pooled correlation with the pooled covariance, \eqn{\tilde\Gamma},
#' as the robust meat (its diagonal as the weights for
#' `estimator = "DWLS"`). Because there is only one fit and one rotational gauge, this
#' route bypasses the per-imputation alignment: `target_method` and
#' `align_unrotated` do not apply. The fitted object carries native scaled-shifted
#' chi-square statistics and sandwich SEs that already reflect the
#' multiple-imputation uncertainty, so the chi-square is not D2-pooled and the
#' likelihood-ratio-based AIC/BIC/ECVI are `NA`; it is returned in the `mi_fit`
#' slot, with the per-imputation `fits` retained for diagnostics. The pooled fit
#' uses the same [estimate_control()] and [rotate_control()] tuning (including
#' any rotation-engine extras) as the per-imputation fits. At least 20
#' imputations are recommended for the scaled-shifted statistic, and more (around
#' 100) at higher rates of missingness (Sriutaisuk et al. 2025). The
#' polychoric/tetrachoric (ordinal) case is the primary, best-evaluated target; the
#' continuous-Pearson case uses the same recipe but is less benchmarked.
#'
#' @section Conditions:
#' Errors and warnings raised by `efa_mi()` are classed, with an `efa_pooled_`
#' prefix (`efa_consensus_` for the consensus target) -- except the dots validation
#' shared with [efa_fit()], which signals `efa_flat_knob_in_dots` or `efa_renamed_arg`
#' -- so they can be caught programmatically. The message shown explains what went
#' wrong and, where relevant, how to fix it.
#'
#' @param data_list A list of length \eqn{m}, where \eqn{m} is the number of
#' imputations. Each list element is a data frame or matrix of raw data, or a
#' correlation matrix. See argument `x` in [efa_fit()]. A `mids` object from
#' \pkg{mice} must be converted first, with `mice::complete(x, "all")`.
#' @param p Numeric in \eqn{(0, 1)}. One minus the confidence level for the
#' pooled confidence intervals, whichever `se` method produced them
#' (`"information"`, `"np-boot"`, or `"sandwich"`). For example, `p = .05`
#' gives 95% intervals.
#' @param target_method Character. How rotated solutions are aligned across imputations
#' before pooling: `"first_target"` (the default) aligns every imputation to the first
#' imputation's rotated solution, while `"consensus"` refines a centroid target by
#' Generalized Procrustes Analysis, started from the medoid imputation so that the
#' pooled rotated solution does not depend on the order of `data_list` (orthogonal
#' rotations only). See *Aligning solutions across imputations* in Details.
#' @param align_unrotated Character. How unrotated loadings are aligned before pooling:
#' `"signed_tucker_congruence"` (the default; sign/permutation via Tucker congruence,
#' anchored on the medoid imputation and returned in the extraction's canonical
#' gauge), `"procrustes"` (orthogonal Procrustes to the first imputation), or
#' `"none"`. See *Aligning solutions across imputations* in Details.
#' @param fit_pool_method Character. Only `"D2"` is implemented for pooling
#' chi-square-type fit. If no chi-square is available, only residual-based
#' fit and descriptive quantities are returned. See *Pooling the model chi-square and
#' fit indices* in Details.
#' @param consensus_args List of additional arguments controlling the
#' GPA-consensus iteration when `target_method = "consensus"`. Recognised tuning
#' parameters include the convergence tolerances `tol` and `loss_tol`, the
#' iteration bounds `min_iter` and `max_iter`, the target-update damping `alpha`,
#' the multi-start controls `multi_start` and `starts`, and `start`, which
#' overrides the medoid imputation the iteration is otherwise started from.
#' @param procrustes_args List of [efa_procrustes()] algorithm controls for
#' fixed-target alignment, for example `oblique_maxit` or `oblique_random_starts`.
#' The loadings `A`, the alignment `Target`, the `rotation` family, and the
#' cross-product `S` are derived from the imputations and cannot be set here.
#' @param rmsea_ci_level Numeric. Confidence level for the RMSEA CI.
#' @param rmsr_upper `r lifecycle::badge("deprecated")` Deprecated and ignored.
#' `efa_mi()` now always computes RMSR the same way, from the unique
#' off-diagonal residuals; SRMR is reported alongside it. Supplying it to
#' [efa_mi()] signals a deprecation warning; the superseded [EFA_POOLED()]
#' accepts it silently.
#' @param ... Additional arguments passed to [efa_fit()] (e.g. `estimator`, `rotation`, `se`,
#' `n_factors`, `N`). These select the estimator, rotation, standard-error method, and
#' fit indices used for every imputation; see [efa_fit()] for the available options, their
#' properties, and which combinations are valid. Two of them shape the pooled object
#' rather than a single fit: `seed` sets the random state once for the whole
#' `efa_mi()` call -- every component bootstrap and every random-start rotation draws
#' from it, so a seeded call is reproducible as a whole, and the caller's random stream
#' is restored afterwards -- and `b_boot` sets the number of bootstrap replicates drawn
#' per imputation under `se = "np-boot"`, which is what the pooled within-imputation
#' variances are estimated from and is recorded in `settings$b_boot`. The
#' [estimate_control()] and
#' [rotate_control()] objects are accepted through `...` as well, although they are not
#' declared formals: pass them as `estimate_control =` / `rotate_control =` exactly as you
#' would to [efa_fit()].
#'
#' @return A list of class `c("efa_mi", "EFA_POOLED", "efa", "EFA")` containing
#' pooled estimates, residuals, fit indices, the individual fits, and MI
#' diagnostics. The trailing legacy classes keep `inherits(x, "EFA_POOLED")` and
#' the single-fit EFA accessors and S3 dispatch working. In
#' addition to the slots inherited from [efa_fit()] (including `SE`, `CI`, and,
#' on the bootstrap path, `replicates`), the object carries:
#' \describe{
#' \item{SE, CI}{Pooled standard errors and confidence intervals, named as
#' [efa_fit()] names them: where a pooled communality standard error and interval
#' are produced, they are `SE$communalities` and `CI$communalities` on every route.
#' The Rubin routes (`se = "information"`, `se = "np-boot"`) additionally return
#' them under the compatibility alias `h2`, which holds the same values. The
#' analytic route builds the communality family only when a rotation was requested;
#' an unrotated analytic pool reports `uniquenesses` instead.}
#' \item{fit_indices}{The pooled fit indices. Every route reports `chi`, `df`,
#' `p_chi`, `CAF`, `CFI`, `TLI`, `RMSEA`, `RMSEA_LB`, `RMSEA_UB`, `AIC`, `BIC`,
#' `ECVI`, `RMSR`, `SRMR`, `chi_null`, `df_null`, `p_null`, and `pool_method`
#' under those names and in that order. `pool_method` records the rule the model
#' chi-square was pooled with (`"D2"`); it is `NA` on the `se = "sandwich"` (MI2S)
#' path, which fits once on the pooled inputs and reports that fit's own scaled
#' statistic rather than pooling several. On that path a few extra scaled-statistic
#' fields are appended after the common block, for advanced diagnostics.}
#' \item{standardized_residuals}{The pooled residuals divided by their pooled
#' bootstrap standard errors, with a zero diagonal. Returned on the
#' `se = "np-boot"` path only, the one route that pools a residual standard error.}
#' \item{MI}{Multiple-imputation diagnostics for each pooled parameter family.
#' On the bootstrap path: `unrot_loadings`, `communalities`, `residuals`, optionally
#' `rot_loadings`, `Phi`, `Structure`, and `fit_indices_descriptive`, plus
#' integer vectors `bootstrap_source_failures` (replicates the component [efa_fit()]
#' could not fit), `bootstrap_rotation_failures` (replicates whose Procrustes
#' alignment to the target was invalid), and `bootstrap_rotation_valid` (those
#' that entered the pool, `B - source - rotation` failures). Both paths use the
#' plain Rubin (1987) df. On the analytic path (`se = "information"`):
#' `unrot_loadings` and `uniquenesses`, plus, when a rotation was requested,
#' `rot_loadings`, `communalities`, and (oblique) `Phi` and `Structure`. The
#' communality family is keyed by its canonical name here, without the `SE`/`CI`
#' alias, so each family is counted once in the printed FMI/RIV summary. Each per-family
#' entry is a list with `RIV` (relative increase in variance), `FMI` (the
#' fraction of missing information, reported as Rubin's asymptotic
#' \eqn{\lambda = RIV / (1 + RIV)}), and `df`; the
#' rotated families on the analytic path additionally carry a `method` string
#' recording the gauge alignment used (`"gauge_invariant"` for communalities and
#' `"signed_permutation_approx"` for rotated loadings and, for oblique rotations,
#' factor correlations and structure coefficients). `fit_indices_descriptive`, on
#' the bootstrap path, pools every fit index the bootstrap replicates carry, so the
#' structural constants among them (`df`, `df_null`) appear with a standard error
#' of 0. The RMSEA confidence bounds are not among them: the replicate fits run
#' without confidence intervals, so no per-replicate value exists to pool.}
#' \item{mi_fit}{On the `se = "sandwich"` (MI2S) path only: the single [efa_fit()]
#' fit on the pooled correlation matrix \eqn{\bar r} and pooled asymptotic
#' covariance \eqn{\tilde\Gamma}. Its `orig_R` is \eqn{\bar r} and its `Gamma`
#' is \eqn{\tilde\Gamma}; the pooled `SE` and `CI` are taken from it, as are the
#' pooled `fit_indices` (put into the common order above and extended with
#' `pool_method`, while `mi_fit` keeps [efa_fit()]'s own layout). `MI` is `NULL`
#' on this path because the imputation uncertainty is carried by
#' \eqn{\tilde\Gamma} rather than by per-parameter Rubin pooling.}
#' \item{mi_diagnostics}{Diagnostics for the pooled model fit, `NULL` on the
#' `se = "sandwich"` (MI2S) path, where there is one fit and no D2 pool. `m` is the
#' number of imputations that entered the pool. `D2_F`, `D2_df1`, `D2_df2`,
#' `D2_chi_asymptotic`, `ARIV` and `FMI` describe the D2 pool of the model
#' chi-square (the average relative increase in variance and the fraction of
#' missing information it implies), and `chi_bar_naive` is the plain mean of the
#' per-imputation statistics for comparison; the `*_null` entries are the same
#' quantities for the independence baseline. `D2_F` is the rule's raw statistic and
#' is reported unfloored, so it is negative whenever the between-imputation
#' variability of the component statistics exceeds the pooled discrepancy -- a
#' diagnostic of the pool rather than a fit statistic. The reported fit is not
#' affected: the pooled chi-square is floored at zero and its p-value is 1 in that
#' case. `chi_cfi` and `chi_null_cfi` are the
#' pooled model and baseline **chi-squares** on the common \eqn{N - 1}
#' noncentrality scale. `chi_cfi` is the statistic the reported RMSEA is formed
#' from; the pair also gives a reference CFI formed the conventional way,
#' `1 - (chi_cfi - df) / (chi_null_cfi - df_null)` (and analogously for TLI) --
#' a different quantity from the reported CFI/TLI, which average the
#' per-imputation indices.}
#' \item{mi_admissibility}{Admissibility and convergence of the component fits, kept on
#' the pooled object so a saved solution carries the record independently of `fits`: `m`
#' (the number of imputations that entered the pool), `heywood_imputations` (the indices
#' of the fits with at least one Heywood case -- an improper solution where a variable's
#' communality is at or above 1, or its uniqueness is fixed at the estimation
#' boundary), `n_heywood_items` (the number of flagged variables per imputation),
#' `nonconverged` (the indices whose extraction
#' reported a non-zero convergence code), and `iter` (the iterations each extraction
#' used). Averaging aligned solutions pulls boundary communalities back inside the
#' admissible range, so a pooled matrix with no Heywood case can still rest on component
#' fits that had them; `summary()` reports the pooled count together with these.}
#' \item{fits}{The list of \eqn{m} component [efa_fit()] fits, in the order of
#' `data_list`, kept for per-imputation diagnostics. On the MI2S path these are the
#' per-imputation fits whose inputs were pooled, not the pooled fit itself (which
#' is `mi_fit`).}
#' \item{alignment}{Metadata from aligning the rotated solutions, `NULL` when no
#' rotation was requested or on the MI2S path (one fit, one gauge). Under
#' `target_method = "first_target"`: the `method` used, the `target` it aligned to,
#' the per-imputation `target_rotations`, the indices of any
#' `point_rotation_failures`, and whether every inner alignment `converged`. Under
#' `target_method = "consensus"` it is the full [efa_procrustes()]-based GPA record:
#' the converged `target`, the `aligned_loadings` and `aligned_phi`, the iteration
#' `history`, convergence flags, and the multi-start summary.}
#' \item{settings}{The component fits' [efa_fit()] settings with the pooling
#' settings added: `pooled` (always `TRUE`), `pooled_N` and `N` (the mean N across
#' imputations), `n_imputations`, `component_se` (the `se` the component fits
#' used), `target_method`, `align_unrotated`, `fit_pool_method`, `p`, `ci` and
#' `rmsea_ci_level`. `se` records what was actually pooled, so it
#' is `"none"` when pooled standard errors could not be produced although the
#' component fits computed them (`component_se` keeps the request).}
#' }
#'
#'
#' @references
#' Anderson, T. W., & Rubin, H. (1956). Statistical inference in factor analysis.
#' In *Proceedings of the Third Berkeley Symposium on Mathematical Statistics and
#' Probability* (Vol. 5, pp. 111-150). University of California Press.
#'
#' Archer, C. O., & Jennrich, R. I. (1973). Standard errors for rotated factor
#' loadings. *Psychometrika*, 38(4), 581-592.
#'
#' Barnard, J., & Rubin, D. B. (1999). Small-sample degrees of freedom with
#' multiple imputation. *Biometrika*, 86(4), 948-955.
#'
#' Bentler, P. M. (1990). Comparative fit indexes in structural models.
#' *Psychological Bulletin*, 107(2), 238-246.
#'
#' Chung, S., & Cai, L. (2019). Alternative multiple imputation inference for
#' categorical structural equation modeling. *Multivariate Behavioral
#' Research*, 54(3), 323-337.
#'
#' Gower, J. C. (1975). Generalized Procrustes analysis. *Psychometrika*,
#' 40(1), 33-51.
#'
#' Jennrich, R. I. (1973). Standard errors for obliquely rotated factor
#' loadings. *Psychometrika*, 38(4), 593-604.
#'
#' Jennrich, R. I. (1974). Simplified formulae for standard errors in
#' maximum-likelihood factor analysis. *British Journal of Mathematical and
#' Statistical Psychology*, 27(1), 122-131.
#'
#' Lawley, D. N., & Maxwell, A. E. (1971). *Factor analysis as a statistical
#' method* (2nd ed.). Butterworths.
#'
#' Li, K. H., Meng, X.-L., Raghunathan, T. E., & Rubin, D. B. (1991).
#' Significance levels from repeated p-values with multiply-imputed data.
#' *Statistica Sinica*, 1(1), 65-92.
#'
#' Lorenzo-Seva, U., & Van Ginkel, J. R. (2016). Multiple imputation of missing
#' values in exploratory factor analysis of multidimensional scales. *Anales de
#' Psicologia*, 32(2), 596-608.
#'
#' Rubin, D. B. (1987). *Multiple imputation for nonresponse in surveys*. Wiley.
#'
#' Schoenemann, P. H. (1966). A generalized solution of the orthogonal
#' Procrustes problem. *Psychometrika*, 31(1), 1-10.
#'
#' Sriutaisuk, S., Liu, Y., Chung, S., Kim, H., & Gu, F. (2025). Evaluating
#' imputation-based fit statistics in structural equation modeling with ordinal
#' data: The MI2S approach. *Educational and Psychological Measurement*, 85(1),
#' 82-113.
#'
#' Tucker, L. R., & Lewis, C. (1973). A reliability coefficient for maximum
#' likelihood factor analysis. *Psychometrika*, 38(1), 1-10.
#'
#' van Ginkel, J. R., & Kroonenberg, P. M. (2014). Using generalized Procrustes
#' analysis for multiple imputation in principal component analysis. *Journal of
#' Classification*, 31(2), 242-269.
#'
#' Zhang, G., & Preacher, K. J. (2015). Factor rotation and standard errors in
#' exploratory factor analysis. *Journal of Educational and Behavioral
#' Statistics*, 40(6), 579-603.
#'
#' Zhang, G., Preacher, K. J., & Jennrich, R. I. (2012). The infinitesimal
#' jackknife with exploratory factor analysis. *Psychometrika*, 77(4), 634-648.
#'
#' @family factor analysis
#'
#' @export
#'
#' @examples
#'
#' # create a list of three datasets, mimicking a list you would obtain from
#' # e.g. mice.
#' dat_list <- lapply(1:3, function(x) GRiPS_raw[sample(1:nrow(GRiPS_raw), replace = TRUE),])
#' mod <- efa_mi(dat_list, n_factors = 1, estimator = "ML")
#' mod
#'
#' \donttest{
#' # add computation of standard errors and CIs
#' mod <- efa_mi(dat_list, n_factors = 1, estimator = "ML", se = "np-boot")
#' mod
#' }
efa_mi <- function(data_list,
p = 0.05,
target_method = c("first_target", "consensus"),
align_unrotated = c("signed_tucker_congruence", "none", "procrustes"),
fit_pool_method = c("D2"),
consensus_args = list(),
procrustes_args = list(),
rmsea_ci_level = .90,
rmsr_upper = lifecycle::deprecated(),
...) {
efa_args <- list(...)
# A flat tuning knob or the former `method` spelling in the dots would only surface from
# inside the first per-imputation fit, with the error attributed to efa_fit(); reject it
# here so the message names the function the user called.
.reject_flat_knobs(...names(), fn = "efa_mi")
# The per-imputation do.call(efa_fit, ...) binds the dots by R's argument matching,
# which accepts unique abbreviations of efa_fit()'s formals (e.g. `rotate_c =` for
# `rotate_control`), but the pooling routes below read `efa_args` by exact name.
# Canonicalize abbreviated names up front with the same exact-first, unique-prefix
# matching do.call() applies; ambiguous or unknown names are left untouched for
# efa_fit() to reject.
arg_names <- names(efa_args)
if (length(arg_names)) {
efa_fit_formals <- setdiff(names(formals(efa_fit)), "...")
canonical <- pmatch(arg_names, efa_fit_formals)
names(efa_args)[!is.na(canonical)] <- efa_fit_formals[canonical[!is.na(canonical)]]
}
# A mids object is itself a list, so it passes the checks below and only fails deep in
# the per-dataset assertion with a message naming the lapply variable. It is the most
# likely wrong input here, so name it and give the conversion.
if (inherits(data_list, "mids")) {
cli::cli_abort(
c("{.arg data_list} must be a list of completed datasets, not a {.cls mids} object.",
"i" = "Convert it first with {.code mice::complete(x, \"all\")}."),
class = "efa_pooled_mids_input"
)
}
checkmate::assert_list(data_list, null.ok = FALSE)
# Arity is asserted here rather than through checkmate's `min.len`, so that too few
# imputations signal the documented `efa_pooled_min_fits` condition instead of a
# plain checkmate error.
if (length(data_list) < 2L) {
cli::cli_abort(
c("At least two imputations are required for MI pooling.",
"i" = "{.arg data_list} has {length(data_list)} element{?s}."),
class = "efa_pooled_min_fits"
)
}
lapply(data_list, checkmate::assert_multi_class, c("matrix", "data.frame"))
checkmate::assert_number(p, na.ok = FALSE, lower = 0, upper = 1)
checkmate::assert_list(consensus_args, null.ok = FALSE)
checkmate::assert_list(procrustes_args, null.ok = FALSE)
.efa_pooled_check_procrustes_args(procrustes_args)
checkmate::assert_number(rmsea_ci_level, na.ok = FALSE, lower = 0, upper = 1)
# `rmsr_upper` never changed a returned value; the argument's documentation gives the
# argument, and the warning below repeats it for the caller.
if (lifecycle::is_present(rmsr_upper)) {
lifecycle::deprecate_warn(
when = "1.1.0",
what = "efa_mi(rmsr_upper)",
details = c(
"i" = paste("It selected between two RMSR conventions that coincide for a symmetric",
"residual matrix, so it never affected the reported RMSR."),
"i" = paste("RMSR is the root mean square of the unique off-diagonal residuals; SRMR,",
"which divides the same sum by the non-redundant elements, is reported",
"alongside it.")
)
)
}
if (p <= 0 || p >= 1) {
cli::cli_abort("{.arg p} must be strictly between 0 and 1.", class = "efa_pooled_bad_p")
}
if (rmsea_ci_level <= 0 || rmsea_ci_level >= 1) {
cli::cli_abort("{.arg rmsea_ci_level} must be strictly between 0 and 1.", class = "efa_pooled_bad_ci_level")
}
if (!is.null(efa_args$ci) && length(efa_args$ci) == 1L &&
is.finite(efa_args$ci) &&
abs(as.numeric(efa_args$ci) - (1 - p)) > sqrt(.Machine$double.eps)) {
cli::cli_warn("{.fn efa_mi} uses {.arg p}, not the component {.fn efa_fit} argument {.arg ci}, to set pooled bootstrap/MI confidence intervals.",
class = "efa_pooled_ci_ignored")
}
target_method <- .match_arg_ci(target_method)
align_unrotated <- .match_arg_ci(align_unrotated)
fit_pool_method <- .match_arg_ci(fit_pool_method)
m_imp <- length(data_list)
## -------------------------------------------------------------------------
## Fit EFA to each imputed dataset
## -------------------------------------------------------------------------
# Pooling is all-or-nothing, so one unfittable imputation ends the call. Name it:
# the component condition is raised by efa_fit() and identifies no imputation, which
# leaves a long `data_list` to be bisected by hand. The original condition is kept as
# the parent so its own diagnosis stays visible.
fits <- vector("list", m_imp)
# The imputations are versions of one data set, so the per-fit "computing correlations
# from the raw data" note states one fact about the input; report it once instead of once
# per imputation.
.cor_note_once(
for (d in seq_len(m_imp)) {
fits[[d]] <- tryCatch(
do.call(efa_fit, c(list(x = data_list[[d]]), efa_args)),
error = function(e) {
cli::cli_abort(
c("Imputation {d} could not be fitted.",
"i" = "Inspect {.code data_list[[{d}]]}, then re-fit or replace it."),
class = "efa_pooled_fit_failed", parent = e
)
}
)
}
)
# `fits` is returned to the user, so it keeps whatever names `data_list` carried.
names(fits) <- names(data_list)
.efa_pooled_check_fits(fits)
# Select the SE-pooling pathway from the component fits' shared se method. A
# heterogeneous se cannot be pooled into a single MI estimate, so abort rather
# than silently producing an uninterpretable mixture.
route <- .efa_pooled_route(fits)
if (identical(route, "mixed")) {
.efa_pooled_mixed_se_abort(fits, data_list)
}
settings <- fits[[1]]$settings
estimator <- settings$estimator
rotation <- settings$rotation
var_names <- rownames(fits[[1]]$orig_R)
if (is.null(var_names)) {
var_names <- colnames(data_list[[1]])
}
rotation_type <- if (is.null(rotation)) "none" else .rotation_family(rotation)
## -------------------------------------------------------------------------
## MI2S route (Chung & Cai 2019; Sriutaisuk et al. 2025): when the component
## fits carry sandwich (robust) standard errors, pool the correlation matrix
## and the asymptotic covariance of its off-diagonal entries across imputations
## and fit a single model on the pooled inputs. The single fit yields native
## scaled-shifted chi-square and sandwich SEs that reflect the multiple-
## imputation uncertainty, so this path bypasses the per-imputation rotation
## alignment and Rubin pooling used by the bootstrap/information routes below.
## -------------------------------------------------------------------------
if (identical(route, "sandwich")) {
return(.efa_pooled_mi2s(
fits = fits, data_list = data_list, efa_args = efa_args,
settings = settings, estimator = estimator, rotation = rotation,
rotation_type = rotation_type, target_method = target_method,
align_unrotated = align_unrotated, fit_pool_method = fit_pool_method,
p = p, rmsea_ci_level = rmsea_ci_level
))
}
## -------------------------------------------------------------------------
## Extract and align unrotated loadings
## -------------------------------------------------------------------------
unrot_loadings <- lapply(.extract_list_object(fits, "unrot_loadings"),
.change_class, "matrix")
aligned_unrot <- .efa_pooled_align_unrotated_list(
unrot_loadings = unrot_loadings,
align_unrotated = align_unrotated
)
unrot_loadings_aligned <- aligned_unrot$loadings
unrot_align_meta <- aligned_unrot$meta
mean_unrot_loadings <- .average_matrices(unrot_loadings_aligned)
## -------------------------------------------------------------------------
## Align rotated loadings and Phi, if a rotation was requested
## -------------------------------------------------------------------------
# A single-factor solution cannot be rotated and has no factor correlations, so
# an oblique request on one factor is treated as the no-Phi case throughout,
# matching a single-fit EFA() (which returns Phi = NULL for one factor).
oblique <- rotation_type == "oblique" && ncol(unrot_loadings_aligned[[1]]) >= 2L
phis <- NULL
structure_loadings <- NULL
if (rotation_type != "none") {
rot_loadings_initial <- lapply(.extract_list_object(fits, "rot_loadings"),
.change_class, "matrix")
if (target_method == "first_target") {
target_rotations <- vector("list", m_imp)
rot_loadings <- vector("list", m_imp)
phis <- vector("list", m_imp)
# One factor is aligned by sign only; an oblique Procrustes is neither
# needed nor well defined for a single column.
proc_rotation <- if (oblique) "oblique" else "orthogonal"
rot_loadings[[1]] <- rot_loadings_initial[[1]]
if (oblique) {
phis[[1]] <- fits[[1]]$Phi
}
point_rotation_failures <- logical(m_imp)
for (d in 2:m_imp) {
target_rotations[[d]] <- do.call(
efa_procrustes,
c(list(A = unrot_loadings[[d]],
Target = rot_loadings_initial[[1]],
rotation = proc_rotation),
procrustes_args)
)
point_rotation_failures[d] <- isFALSE(target_rotations[[d]]$valid)
rot_loadings[[d]] <- target_rotations[[d]]$loadings
if (oblique) {
phis[[d]] <- target_rotations[[d]]$Phi
}
}
if (any(point_rotation_failures)) {
cli::cli_warn(
c("At least one fixed-target Procrustes alignment could not be aligned to a valid rotation.",
"i" = "The pooled point estimates still use the best available {.fn efa_procrustes} alignment; inspect {.code alignment$point_rotation_failures}."),
class = "efa_pooled_align_failed"
)
}
final_target <- rot_loadings_initial[[1]]
inner_converged <- vapply(target_rotations[-1L], function(x) {
isTRUE(x$valid) && (is.null(x$convergence) || isTRUE(x$convergence))
}, logical(1L))
alignment <- list(method = "first_target",
target = final_target,
target_rotations = target_rotations,
point_rotation_failures = which(point_rotation_failures),
converged = all(inner_converged))
} else if (target_method == "consensus") {
# The GPA iteration starts from one imputation's rotated solution and moves it
# toward the centroid, inheriting the rotational gauge of wherever it started, so
# the start decides the gauge of the pooled rotated solution. Start it at the
# medoid rather than at the first imputation: the medoid is a property of the set,
# so the converged target -- and with it the pooled rotated pattern -- no longer
# depends on the order of `data_list`. `"first_target"` keeps its documented
# first-imputation anchor, which is what its name promises. An explicit `start` in
# `consensus_args` wins; it is matched the way do.call() would match it, so a
# partially spelled one is not silently duplicated.
consensus_call <- consensus_args
start_supplied <- any(!is.na(pmatch(names(consensus_args), "start")))
if (!start_supplied) {
consensus_call$start <- .efa_pooled_medoid_anchor(rot_loadings_initial)
}
consensus <- do.call(
.gpa_consensus_target,
c(list(unrotated_list = unrot_loadings,
init_targets = rot_loadings_initial,
rotation = rotation_type),
consensus_call)
)
if (!isTRUE(consensus$converged)) {
cli::cli_warn(
c("Consensus Procrustes alignment did not meet its convergence criterion.",
"i" = "Inspect {.code alignment$history} and consider stricter or multi-start {.arg consensus_args}."),
class = "efa_pooled_align_failed"
)
}
rot_loadings <- consensus$aligned_loadings
phis <- consensus$aligned_phi
final_target <- consensus$target
alignment <- consensus
}
mean_rot_loadings <- .average_matrices(rot_loadings)
rownames(mean_rot_loadings) <- var_names
if (oblique) {
mean_phis <- .average_matrices(phis)
# Ensure mean_phis is symmetric (needed, as due to floating point imprecision,
# the averaged matrix can be asymmetric, leading to issues in downstream checks
# of matrix symmetry)
mean_phis <- (mean_phis + t(mean_phis)) / 2
structure_loadings <- Map(function(L, Phi) L %*% Phi, rot_loadings, phis)
# Keep Structure parallel to the returned pooled pattern matrix and Phi:
# it is the plug-in structure of the pooled solution, not the arithmetic
# mean of the imputation-specific structure matrices.
mean_structure_loadings <- mean_rot_loadings %*% mean_phis
dimnames(mean_structure_loadings) <- dimnames(mean_rot_loadings)
} else {
mean_phis <- NULL
mean_structure_loadings <- NULL
}
} else {
rot_loadings <- NULL
mean_rot_loadings <- NULL
mean_phis <- NULL
mean_structure_loadings <- NULL
final_target <- NULL
alignment <- NULL
}
## -------------------------------------------------------------------------
## Pooled observed and model-implied correlation matrices; residuals and RMSR
## -------------------------------------------------------------------------
orig_R_list <- .extract_list_object(fits, "orig_R")
pooled_orig_R <- .average_matrices(orig_R_list)
if (oblique) {
common_R <- mean_rot_loadings %*% mean_phis %*% t(mean_rot_loadings)
} else if (rotation_type != "none") {
common_R <- mean_rot_loadings %*% t(mean_rot_loadings)
} else {
common_R <- mean_unrot_loadings %*% t(mean_unrot_loadings)
}
h2 <- diag(common_R)
names(h2) <- rownames(common_R)
model_implied_R <- common_R
diag(model_implied_R) <- 1
dimnames(model_implied_R) <- list(var_names, var_names)
residuals <- pooled_orig_R - model_implied_R
diag(residuals) <- 0
dimnames(residuals) <- list(var_names, var_names)
RMSR <- .rmsr(residuals)
## -------------------------------------------------------------------------
## Variance-accounted tables
## -------------------------------------------------------------------------
mean_vars_accounted <- .compute_vars(
L_unrot = mean_unrot_loadings,
L_rot = mean_unrot_loadings,
Phi = NULL
)
if (oblique) {
mean_vars_accounted_rot <- .compute_vars(
L_unrot = mean_unrot_loadings,
L_rot = mean_rot_loadings,
Phi = mean_phis
)
} else if (rotation_type != "none") {
mean_vars_accounted_rot <- .compute_vars(
L_unrot = mean_unrot_loadings,
L_rot = mean_rot_loadings,
Phi = NULL
)
} else {
mean_vars_accounted_rot <- NULL
}
## -------------------------------------------------------------------------
## Fit indices: residual-based from pooled residuals, chi-square-based via D2
## -------------------------------------------------------------------------
Ns <- .efa_pooled_get_Ns(data_list, fits, efa_args)
Ns_ok <- Ns[is.finite(Ns)]
if (length(Ns_ok) == 0L) {
N_pool <- NA_real_
if (!identical(estimator, "PAF")) {
cli::cli_warn(
c("N could not be recovered for any imputation.",
"i" = "Chi-square-based fit indices cannot be computed."),
class = "efa_pooled_no_n"
)
}
} else {
if (length(Ns_ok) < length(Ns)) {
cli::cli_warn(
c("N could not be recovered for every imputation.",
"i" = "Fit indices use the mean of the available Ns."),
class = "efa_pooled_partial_n"
)
}
if (length(unique(Ns_ok)) > 1L) {
cli::cli_warn(
c("The imputed datasets appear to have different N.",
"i" = "Fit indices use the mean N across imputations."),
class = "efa_pooled_unequal_n"
)
}
N_pool <- mean(Ns_ok)
}
pooled_fit <- .efa_pooled_fit_indices(
fits = fits,
pooled_R = pooled_orig_R,
residuals = residuals,
RMSR = RMSR,
N = N_pool,
Ns = Ns,
pool_method = fit_pool_method,
rmsea_ci_level = rmsea_ci_level
)
fit_indices <- pooled_fit$fit_indices
mi_diagnostics <- pooled_fit$mi_diagnostics
## -------------------------------------------------------------------------
## SEs and CIs for pooled MI estimates. Two routes: a bootstrap pool that
## requires component-level replicate cubes, and an analytic pool that
## consumes the per-imputation closed-form SE matrices populated by
## se = "information" via Rubin's (1987) rules.
## -------------------------------------------------------------------------
# The route fixes which pool runs: "information" consumes the per-imputation
# closed-form SE matrices, "np-boot" the replicate cubes. A pooling failure on
# either route is non-fatal and falls back to point-estimate-only pooling,
# signalling the umbrella `efa_pooled_se_unavailable` condition. The "sandwich"
# route returned via .efa_pooled_mi2s() above, and "none" requests no SEs at all.
boot_pooled <- NULL
se_failed <- FALSE
# Shared fallback: a classed analytic-pool abort becomes a point-estimate-only
# solution flagged with the umbrella condition. `route` (captured here) resolves
# to the active route inside each branch, so it labels the warning correctly.
se_unavailable_handler <- function(e) {
se_failed <<- TRUE
.efa_pooled_se_unavailable(route, parent = e)
NULL
}
if (identical(route, "information")) {
# Catch only the analytic pool's own classed aborts (an unreliable/absent
# unrotated covariance, or missing alignment metadata) and fall back to
# point-estimate-only pooling; an unexpected error is a real bug and must
# propagate rather than be silently downgraded to "se unavailable".
boot_pooled <- tryCatch(
.efa_pooled_analytic_pool(
fits = fits,
unrot_loadings_aligned = unrot_loadings_aligned,
align_meta = unrot_align_meta,
ci = 1 - p,
align_unrotated = align_unrotated,
rotation_type = rotation_type,
rot_loadings = rot_loadings,
phis = phis,
structure_loadings = structure_loadings,
mean_structure_loadings = mean_structure_loadings,
mean_phis = mean_phis,
h2 = h2
),
efa_pooled_unreliable_vcov = se_unavailable_handler,
efa_pooled_no_vcov = se_unavailable_handler,
efa_pooled_analytic_align_meta_missing = se_unavailable_handler
)
} else if (identical(route, "np-boot")) {
# The bootstrap pool soft-returns NULL (with its own classed warning) when it
# cannot pool; that NULL is handled by the fallback check below. It has no
# classed aborts of its own, so call it directly and let any unexpected error
# propagate rather than masking it as "se unavailable".
boot_pooled <- .efa_pooled_bootstrap_pool(
fits = fits,
orig_R_list = orig_R_list,
unrot_loadings_aligned = unrot_loadings_aligned,
mean_unrot_loadings = mean_unrot_loadings,
rot_loadings = rot_loadings,
phis = phis,
structure_loadings = structure_loadings,
mean_structure_loadings = mean_structure_loadings,
final_target = final_target,
rotation_type = rotation_type,
align_unrotated = align_unrotated,
procrustes_args = procrustes_args,
h2 = h2,
residuals = residuals,
alpha = p
)
}
# A soft failure (the helper returned NULL after emitting its own specific
# condition, e.g. too few bootstrap replicates) still downgrades to no SE
# pooling; surface the umbrella condition unless the tryCatch handler already
# did so for a classed abort.
if ((identical(route, "information") || identical(route, "np-boot")) &&
is.null(boot_pooled) && !se_failed) {
.efa_pooled_se_unavailable(route)
}
## -------------------------------------------------------------------------
## Return object
## -------------------------------------------------------------------------
mean_unrot_loadings <- .change_class(mean_unrot_loadings,
c("efa_loadings", "LOADINGS"))
if (!is.null(mean_rot_loadings)) {
mean_rot_loadings <- .change_class(mean_rot_loadings,
c("efa_loadings", "LOADINGS"))
}
if (!is.null(mean_structure_loadings)) {
mean_structure_loadings <- .change_class(mean_structure_loadings,
c("efa_loadings", "LOADINGS"))
}
settings_pooled <- settings
settings_pooled$N <- N_pool
settings_pooled$pooled_N <- N_pool
settings_pooled$pooled <- TRUE
settings_pooled$component_se <- settings$se
# Downgrade se only on the fallback path: a requested se = "information" /
# "np-boot" whose pooled SEs could not be produced (an `efa_pooled_se_unavailable`
# warning was emitted above). The se = "none" route never requested SEs, so it
# keeps "none" without a warning or downgrade. `component_se` retains the
# requested method so print.efa_mi() can still describe what the component
# fits used.
if (is.null(boot_pooled) && !identical(route, "none")) {
settings_pooled$se <- "none"
}
settings_pooled$n_imputations <- m_imp
settings_pooled$target_method <- target_method
settings_pooled$align_unrotated <- align_unrotated
settings_pooled$fit_pool_method <- fit_pool_method
settings_pooled$p <- p
settings_pooled$ci <- 1 - p
settings_pooled$rmsea_ci_level <- rmsea_ci_level
if (!is.null(boot_pooled) && !is.null(boot_pooled$n_boot)) {
settings_pooled$b_boot <- boot_pooled$n_boot
}
results <- list(
h2 = h2,
unrot_loadings = mean_unrot_loadings,
vars_accounted = mean_vars_accounted,
fit_indices = fit_indices,
model_implied_R = model_implied_R,
residuals = residuals,
orig_R = pooled_orig_R,
settings = settings_pooled,
fits = fits,
alignment = alignment,
mi_diagnostics = mi_diagnostics,
mi_admissibility = .efa_pooled_admissibility(fits)
)
if (rotation_type != "none") {
results$rot_loadings <- mean_rot_loadings
results$vars_accounted_rot <- mean_vars_accounted_rot
}
if (oblique) {
results$Phi <- mean_phis
results$Structure <- mean_structure_loadings
}
if (!is.null(boot_pooled)) {
results$SE <- boot_pooled$SE
results$CI <- boot_pooled$CI
results$MI <- boot_pooled$MI
# Single-bracket list assignment preserves a present-but-NULL `replicates`
# slot on the analytic path, matching the EFA() schema contract pinned in
# test-EFA-fields.R; `results$replicates <- NULL` would remove the slot.
results["replicates"] <- list(boot_pooled$replicates)
if (!is.null(boot_pooled$SE$residuals)) {
# The residual diagonal is fixed at 0 with SE 0, so 0/0 would yield NaN on
# the diagonal; the off-diagonal standardised residuals are the meaningful
# quantities, so set the diagonal to 0 (matches single-fit EFA()).
std_resid <- results$residuals / boot_pooled$SE$residuals
diag(std_resid) <- 0
results$standardized_residuals <- std_resid
}
}
# The trailing legacy classes are load-bearing: "EFA_POOLED" keeps
# `inherits(x, "EFA_POOLED")` resolving, and "efa"/"EFA" keep the shared print,
# format, summary, and residuals methods (and the single-fit accessors) working.
class(results) <- c("efa_mi", "EFA_POOLED", "efa", "EFA")
results
}
## =============================================================================
## Internal helpers for efa_mi
## =============================================================================
.efa_pooled_setting_chr <- function(f, name) {
# A component fit's settings value as a length-1 character (NA when absent), for
# comparing settings across imputations.
val <- f$settings[[name]]
if (is.null(val)) NA_character_ else as.character(val)
}
.efa_pooled_route <- function(fits) {
# Decide which multiple-imputation SE-pooling pathway a set of component fits
# selects, from the standard-error method recorded on each fit. Pooling is only
# defined when every imputation used the same se, so a heterogeneous (or
# absent) se returns "mixed" and the caller aborts. Returns one of "none",
# "np-boot", "information", "sandwich", or "mixed".
ses <- vapply(fits, .efa_pooled_setting_chr, character(1), name = "se")
if (anyNA(ses) || length(unique(ses)) != 1L) {
return("mixed")
}
ses[[1L]]
}
.efa_pooled_check_procrustes_args <- function(procrustes_args) {
# Restrict `procrustes_args` to efa_procrustes() algorithm controls. The matrices and
# the rotation family are derived from the imputations, and `S` is the cross-product
# of the loadings being rotated: supplying any of them here would either duplicate a
# formal do.call() already supplies or replace an internally derived quantity with one
# that does not belong to the alignment being run. efa_procrustes() has no dots, so R
# would match a partially spelled name onto a formal; canonicalize the names the same
# way before deciding, or `Tar =` would slip through as an override of `Target`.
# `charmatch()` rather than `pmatch()`: it separates an abbreviation that matches
# several formals (0) from one that matches none (NA), so an ambiguous name is
# diagnosed as ambiguous instead of as a typo.
if (length(procrustes_args) == 0L) {
return(invisible(TRUE))
}
nms <- names(procrustes_args)
if (is.null(nms) || any(!nzchar(nms))) {
cli::cli_abort(
c("Every element of {.arg procrustes_args} must be named.",
"i" = "Positional arguments cannot be matched to {.fn efa_procrustes} formals."),
class = "efa_pooled_bad_procrustes_args"
)
}
procrustes_formals <- names(formals(efa_procrustes))
matched <- charmatch(nms, procrustes_formals)
resolved <- !is.na(matched) & matched > 0L
canonical <- nms
canonical[resolved] <- procrustes_formals[matched[resolved]]
reserved <- c("A", "Target", "rotation", "S")
bad_reserved <- nms[canonical %in% reserved]
if (length(bad_reserved) > 0L) {
cli::cli_abort(
c("{.arg procrustes_args} must not set {.arg {bad_reserved}}.",
"i" = "{.fn efa_mi} derives the loadings, the alignment target, the rotation family, and the cross-product {.arg S} from the imputations; pass only {.fn efa_procrustes} algorithm controls (for example {.arg oblique_maxit})."),
class = "efa_pooled_bad_procrustes_args"
)
}
ambiguous <- nms[!is.na(matched) & matched == 0L]
if (length(ambiguous) > 0L) {
cli::cli_abort(
c("{cli::qty(length(ambiguous))}{?An/Some} abbreviated {.arg procrustes_args} name{?s} {cli::qty(length(ambiguous))}match{?es/} more than one {.fn efa_procrustes} argument.",
"x" = "Ambiguous: {.val {ambiguous}}.",
"i" = "Spell {cli::qty(length(ambiguous))}{?it/them} out."),
class = "efa_pooled_bad_procrustes_args"
)
}
bad_unknown <- nms[is.na(matched)]
if (length(bad_unknown) > 0L) {
cli::cli_abort(
c("{.arg procrustes_args} may only contain {.fn efa_procrustes} arguments.",
"x" = "{cli::qty(length(bad_unknown))}Unknown name{?s}: {.val {bad_unknown}}.",
"i" = "Available controls: {.arg {setdiff(procrustes_formals, reserved)}}."),
class = "efa_pooled_bad_procrustes_args"
)
}
invisible(TRUE)
}
.efa_pooled_mixed_se_abort <- function(fits, data_list) {
# Abort a pool whose component fits recorded different `se` methods, naming the
# imputations that broke the shared method. A caller passes one `se` to efa_mi(), so a
# mixture is never something they set directly: efa_fit() records `se = "none"` for a
# fit whose requested standard errors it could not compute -- a correlation matrix
# under `se = "np-boot"` being the reachable case -- and "re-fit with the same se" is
# then advice the caller already followed. Point at the imputations instead.
ses <- vapply(fits, .efa_pooled_setting_chr, character(1), name = "se")
known <- ses[!is.na(ses)]
majority <- if (length(known) > 0L) {
names(sort(table(known), decreasing = TRUE))[[1L]]
} else {
NA_character_
}
odd <- which(is.na(ses) | ses != majority)
bullets <- "The component {.fn efa_fit} fits use different {.arg se} methods, so their standard errors cannot be pooled."
if (!is.na(majority) && length(odd) > 0L) {
odd_ses <- unique(ses[odd])
n_rest <- length(ses) - length(odd)
bullets <- c(
bullets,
"x" = "{cli::qty(length(odd))}Imputation{?s} {.val {odd}} recorded {.code se} {.val {odd_ses}}; the remaining {n_rest} recorded {.val {majority}}."
)
}
cormat_odd <- odd[vapply(data_list[odd], .is_cormat, logical(1))]
bullets <- if (length(cormat_odd) > 0L) {
c(bullets,
"i" = "{cli::qty(length(cormat_odd))}Imputation{?s} {.val {cormat_odd}} {cli::qty(length(cormat_odd))}{?is a correlation matrix/are correlation matrices}, which cannot be bootstrapped, so {.fn efa_fit} fitted {cli::qty(length(cormat_odd))}{?it/them} with {.code se = \"none\"}.")
} else {
c(bullets,
"i" = "Re-fit every imputation with the same {.arg se} (all {.val none}, {.val information}, {.val sandwich}, or {.val np-boot}).")
}
cli::cli_abort(bullets, class = "efa_pooled_mixed_se")
}
.efa_pooled_admissibility <- function(fits) {
# Admissibility and convergence of the component fits, aggregated onto the pooled
# object. Averaging aligned solutions pulls boundary communalities back inside the
# admissible range, so a pooled matrix can be proper while several component fits were
# not; without this record the pooled object holds no evidence of that outside `$fits`,
# and the per-fit conditions raised at fitting time are gone once it is saved.
n_heywood <- vapply(fits, function(f) length(f[["heywood"]]), integer(1))
# Read non-convergence with the same predicate the print layer uses, so the recorded
# indices cannot disagree with the banner a component fit would print on its own.
nonconverged <- vapply(fits, function(f) {
.efa_iteration_nonconvergence(list(convergence = f[["convergence"]],
iter = f[["iter"]],
max_iter = f$settings$max_iter))
}, logical(1))
iter <- vapply(fits, function(f) {
it <- f[["iter"]]
if (is.numeric(it) && length(it) == 1L) as.integer(it) else NA_integer_
}, integer(1))
list(
m = length(fits),
heywood_imputations = which(n_heywood > 0L),
n_heywood_items = n_heywood,
nonconverged = which(nonconverged),
iter = iter
)
}
.efa_pooled_se_unavailable <- function(route, parent = NULL) {
# Umbrella condition for "the requested standard-error method could not be
# pooled". Emitted whenever efa_mi() falls back to point-estimate-only
# pooling on the information / np-boot routes, layered over any specific
# condition the pooling helper raised (carried as `parent` for a classed
# abort, NULL when the helper soft-returned NULL after its own warning).
cli::cli_warn(
c("Pooled standard errors could not be produced for the {.val {route}} standard-error method; the pooled point estimates are returned without standard errors.",
"i" = "Inspect the component {.fn efa_fit} fits and any preceding condition for the cause."),
parent = parent, class = "efa_pooled_se_unavailable"
)
}
## -----------------------------------------------------------------------------
## MI2S (multiple imputation, two-stage) pooled-inputs route for sandwich SEs
## -----------------------------------------------------------------------------
.efa_pooled_mi2s_inputs <- function(fits) {
# Pool the per-imputation correlation matrices and their asymptotic covariances
# into a single correlation matrix r_bar and a single ACOV Gamma_tilde via the
# two-stage pooled-input ACOV of Chung & Cai (2019) and Sriutaisuk, Liu, Chung,
# Kim & Gu (2025, Educ. Psychol. Meas.). This is the ACOV-pooling step of MI2S,
# distinct from the parameter-pooling Rubin's-rules routes (which carry FMI/df):
# r_bar = (1/m) sum_d R_d (element-wise mean)
# Gamma_W = (1/m) sum_d Gamma_d (within-imputation ACOV)
# Gamma_B = (1/(m-1)) sum_d (v_d - v_bar)(v_d - v_bar)' (between, = cov())
# Gamma_tilde = Gamma_W + (1 + 1/m) Gamma_B (total pooled ACOV)
# All quantities stay on the per-fit variance scale (Var(rho-hat)) and in the
# utils::combn(p, 2) off-diagonal order that EFA()'s $Gamma uses, so the pooled
# ACOV feeds the sandwich core unchanged.
m <- length(fits)
R_list <- lapply(fits, function(f) as.matrix(f$orig_R))
G_list <- lapply(fits, function(f) as.matrix(f$Gamma))
p <- ncol(R_list[[1]])
idx <- utils::combn(p, 2L)
pair_ij <- cbind(idx[1L, ], idx[2L, ])
q <- ncol(idx)
r_bar <- Reduce(`+`, R_list) / m
r_bar <- (r_bar + t(r_bar)) / 2 # symmetrise away round-off
diag(r_bar) <- 1
# Stack the off-diagonal correlations (combn order) for the between-imputation
# covariance; stats::cov() uses the m - 1 divisor required by Rubin's rule.
V <- t(vapply(R_list, function(R) R[pair_ij], numeric(q)))
Gamma_W <- Reduce(`+`, G_list) / m
Gamma_B <- stats::cov(V)
Gamma_tilde <- Gamma_W + (1 + 1 / m) * Gamma_B
Gamma_tilde <- (Gamma_tilde + t(Gamma_tilde)) / 2
# Gamma_tilde = Gamma_W + (1 + 1/m) Gamma_B is in exact arithmetic a sum of two
# positive-semidefinite matrices (Gamma_W averages PSD per-fit ACOVs; Gamma_B is
# a sample covariance), so it cannot become indefinite from a small m alone. A
# negative eigenvalue therefore signals floating-point round-off or a degenerate
# / corrupted per-fit Gamma rather than imputation noise. Abort rather than
# silently project to the nearest PD matrix (which would distort the sandwich
# meat and the test statistic without warning).
ev <- eigen(Gamma_tilde, symmetric = TRUE, only.values = TRUE)$values
tol <- sqrt(.Machine$double.eps) * max(abs(ev))
if (min(ev) < -tol) {
cli::cli_abort(
c("The pooled asymptotic covariance is not positive semidefinite.",
"x" = "Its smallest eigenvalue is {.val {min(ev)}}, beyond round-off; a per-imputation asymptotic covariance is likely degenerate or corrupted.",
"i" = "Check the per-imputation {.code $Gamma} matrices (e.g. a near-singular polychoric correlation); re-fitting the affected imputations usually resolves it."),
class = "efa_pooled_mi2s_acov_not_psd"
)
}
# Preserve the off-diagonal labelling/order from the per-fit Gamma so the
# sandwich core and any downstream consumer see the same schema.
dimnames(Gamma_tilde) <- dimnames(G_list[[1]])
dimnames(r_bar) <- dimnames(R_list[[1]])
list(r_bar = r_bar, Gamma_tilde = Gamma_tilde, m = m)
}
.efa_pooled_mi2s <- function(fits, data_list, efa_args, settings, estimator,
rotation, rotation_type, target_method,
align_unrotated, fit_pool_method, p,
rmsea_ci_level) {
# Two-stage (MI2S) pooled-inputs route for component fits carrying sandwich
# (robust) SEs. Pools the correlation matrix and its asymptotic covariance
# across imputations (.efa_pooled_mi2s_inputs), then fits the model once on the
# pooled inputs via the shared .efa_core(), so the resulting object carries
# native scaled-shifted chi-square and sandwich SEs that already reflect the
# multiple-imputation uncertainty. No per-imputation rotation alignment or
# Rubin pooling of estimates is performed on this path.
m_imp <- length(fits)
## ---- Fail closed on inconsistent inputs --------------------------------
ses <- vapply(fits, .efa_pooled_setting_chr, character(1), name = "se")
cor_methods <- vapply(fits, .efa_pooled_setting_chr, character(1),
name = "cor_method")
gamma_ok <- vapply(fits, function(f) {
!is.null(f$Gamma) && !anyNA(f$Gamma)
}, logical(1))
if (anyNA(ses) || !all(ses == "sandwich") ||
length(unique(cor_methods)) != 1L ||
!cor_methods[[1L]] %in% c("poly", "tetra", "pearson") ||
!all(gamma_ok)) {
# The settings can be perfectly consistent and still unsupported here (e.g.
# cor_method = "fiml", whose sandwich carries no poolable correlation ACOV);
# advising the user to "re-fit with the same settings" would then be wrong.
unsupported_cor <- !anyNA(ses) && all(ses == "sandwich") &&
length(unique(cor_methods)) == 1L &&
!cor_methods[[1L]] %in% c("poly", "tetra", "pearson")
hint <- if (unsupported_cor) {
c("i" = "The imputations were fitted with {.code cor_method = \"{cor_methods[[1L]]}\"}, which carries no poolable asymptotic correlation covariance; use {.code se = \"np-boot\"} to pool such fits.")
} else {
c("i" = "Re-fit every imputation with the same {.code se = \"sandwich\"} setting and {.arg cor_method}.")
}
cli::cli_abort(
c("MI2S pooling requires every imputation to be fitted with {.code se = \"sandwich\"} and the same {.arg cor_method} ({.val poly}, {.val tetra}, or {.val pearson}), each carrying a valid asymptotic covariance.",
hint),
class = "efa_pooled_mi2s_inputs_inconsistent"
)
}
cor_method <- cor_methods[[1L]]
n_factors <- settings$n_factors
use_setting <- settings$use
if (is.null(use_setting)) use_setting <- "pairwise.complete.obs"
## ---- Sample size -------------------------------------------------------
Ns <- .efa_pooled_get_Ns(data_list, fits, efa_args)
Ns_ok <- Ns[is.finite(Ns)]
if (length(Ns_ok) == 0L) {
cli::cli_abort(
c("MI2S sandwich pooling requires the sample size {.arg N}.",
"i" = "Supply raw data or {.arg N} for the imputations."),
class = "efa_pooled_mi2s_no_n"
)
}
if (length(unique(Ns_ok)) > 1L) {
cli::cli_warn(
c("The imputed datasets appear to have different N.",
"i" = "MI2S pooling uses the mean N across imputations."),
class = "efa_pooled_unequal_n"
)
}
N_pool <- mean(Ns_ok)
## ---- Imputation-count guidance -----------------------------------------
if (m_imp < 20L) {
cli::cli_warn(
c("MI2S pooling was run with only {m_imp} imputation{?s}.",
"i" = "The scaled-shifted statistic is calibrated for 20 or more imputations (more at higher rates of missingness; Sriutaisuk et al. 2025); interpret the pooled fit and SEs with caution."),
class = "efa_pooled_mi2s_n_too_small"
)
}
## ---- Alignment settings do not apply to a single pooled fit ------------
if (!identical(target_method, "first_target") ||
!identical(align_unrotated, "signed_tucker_congruence")) {
cli::cli_warn(
c("{.arg target_method} and {.arg align_unrotated} are ignored on the MI2S sandwich path.",
"i" = "MI2S pools the inputs and fits a single solution, so no per-imputation rotation alignment is performed."),
class = "efa_pooled_mi2s_alignment_ignored"
)
}
## ---- Pool inputs and fit once ------------------------------------------
pooled <- .efa_pooled_mi2s_inputs(fits)
r_bar <- pooled$r_bar
Gamma_tilde <- pooled$Gamma_tilde
weights <- if (estimator == "DWLS") {
# A non-positive pooled asymptotic variance makes .poly_weight_matrix() abort
# with the low-level efa_dwls_degenerate_weight; relabel it to the documented
# MI2S condition with the "increase imputations" remediation.
tryCatch(
.poly_weight_matrix(diag(Gamma_tilde), ncol(r_bar)),
efa_dwls_degenerate_weight = function(e) {
cli::cli_abort(
c("The pooled asymptotic covariance has a non-positive diagonal entry, so DWLS weights cannot be formed.",
"i" = "Increase the number of imputations (Sriutaisuk et al. 2025 recommend 20 or more, and more at higher rates of missingness)."),
class = "efa_pooled_mi2s_acov_not_psd", parent = e
)
}
)
} else {
NULL
}
# Reuse the same estimate -> rotate -> SE pipeline efa_fit() runs, with the same
# estimation/rotation tuning the component fits used: unbundle the two control
# objects from the component call (defaulting to the constructors' presets,
# exactly as efa_fit() does) and pass the knobs to .efa_core() under its
# historical formal names. The arguments resolved explicitly here are dropped
# from the dots to avoid passing them twice through do.call(); the remaining
# dots are merged over the rotation-engine extras stored in rotate_control(),
# with the dots winning on a name clash (mirroring efa_fit()).
ec <- efa_args$estimate_control
if (is.null(ec)) ec <- estimate_control()
rc <- efa_args$rotate_control
if (is.null(rc)) rc <- rotate_control()
drop_args <- c("n_factors", "N", "estimator", "rotation", "se", "use",
"cor_method", "ci", "seed",
"estimate_control", "rotate_control")
extra_args <- modifyList(rc$extra_args,
efa_args[setdiff(names(efa_args), drop_args)])
core_args <- list(R = r_bar, N = N_pool, weights = weights, Gamma = Gamma_tilde,
np_boot = FALSE, estimator = estimator, rotation = rotation,
type = ec$type, rot_type = rc$type, n_factors = n_factors,
se = "sandwich", ci = 1 - p, use = use_setting,
cor_method = cor_method, max_iter = ec$max_iter,
init_comm = ec$init_comm, criterion = ec$criterion,
criterion_type = ec$criterion_type, abs_eigen = ec$abs_eigen,
start_method = ec$start_method, normalize = rc$normalize,
precision = rc$precision, order_type = rc$order_type,
varimax_type = rc$varimax_type, P_type = rc$p_type, k = rc$k,
randomStarts = rc$random_starts)
# Mirror efa_fit()'s extras splice: an extra whose name is already an explicit
# core argument would abort do.call() with "matched by multiple actual
# arguments", so drop it (the resolved value wins).
extra_args <- extra_args[setdiff(names(extra_args), names(core_args))]
mi_fit <- do.call(.efa_core, c(core_args, extra_args))
# .efa_core()/EFA() compute RMSEA confidence bounds at a fixed 90% level; honor
# the requested rmsea_ci_level on the pooled object by recomputing the bounds
# from the single fit's pooled test statistic, matching how the information and
# bootstrap routes apply rmsea_ci_level (a no-op when rmsea_ci_level == 0.90).
fi <- mi_fit$fit_indices
# A finite point estimate is required as well as a finite statistic: only the bounds are
# re-solved here, so without one there is nothing for the new interval to be an interval
# around, and replacing the bounds would advertise a range for an undefined RMSEA.
if (!is.null(fi) && is.finite(fi$chi) && is.finite(fi$df) && fi$df > 0 &&
is.finite(fi$RMSEA)) {
# `fi$RMSEA` stays as .chi_fit_indices() computed it, so pass it in as the point estimate
# the new interval has to contain rather than letting the helper derive a second one from
# `fi$chi` and `N_pool`.
rmsea_ci <- .efa_pooled_rmsea_ci(fi$chi, fi$df, N_pool, point = fi$RMSEA,
level = rmsea_ci_level)
fi$RMSEA_LB <- unname(rmsea_ci[["lower"]])
fi$RMSEA_UB <- unname(rmsea_ci[["upper"]])
mi_fit$fit_indices <- fi
}
# The pooled fit indices are the single fit's, reported under the same names and in
# the same order as on the other routes so that code reading `names(fit_indices)`
# does not have to branch on the route. `pool_method` is present but `NA`: this path
# applies no pooling rule to the chi-square -- the statistic is the pooled-inputs
# fit's own scaled one -- and an absent field would read as "not recorded" exactly
# where a caller asks whether the chi-square was D2-pooled. The extra quantities the
# scaled statistic carries follow the common block. `mi_fit` itself is left as
# .efa_core() returned it, so it stays comparable with a single efa_fit() solution.
fit_indices_pooled <- mi_fit$fit_indices
if (!is.null(fit_indices_pooled)) {
fit_indices_pooled$pool_method <- NA_character_
fit_indices_pooled <- .efa_pooled_order_fit_indices(fit_indices_pooled)
}
## ---- Assemble the pooled object ----------------------------------------
settings_pooled <- settings
settings_pooled$N <- N_pool
settings_pooled$pooled_N <- N_pool
settings_pooled$pooled <- TRUE
settings_pooled$component_se <- "sandwich"
settings_pooled$se <- "sandwich"
settings_pooled$n_imputations <- m_imp
settings_pooled$target_method <- target_method
settings_pooled$align_unrotated <- align_unrotated
settings_pooled$fit_pool_method <- fit_pool_method
settings_pooled$p <- p
settings_pooled$ci <- 1 - p
settings_pooled$rmsea_ci_level <- rmsea_ci_level
results <- list(
h2 = mi_fit$h2,
unrot_loadings = mi_fit$unrot_loadings,
vars_accounted = mi_fit$vars_accounted,
fit_indices = fit_indices_pooled,
model_implied_R = mi_fit$model_implied_R,
residuals = mi_fit$residuals,
orig_R = mi_fit$orig_R,
settings = settings_pooled,
fits = fits,
alignment = NULL,
mi_diagnostics = NULL,
mi_admissibility = .efa_pooled_admissibility(fits),
mi_fit = mi_fit
)
if (rotation_type != "none") {
results$rot_loadings <- mi_fit$rot_loadings
results$vars_accounted_rot <- mi_fit$vars_accounted_rot
}
if (rotation_type == "oblique") {
results$Phi <- mi_fit$Phi
results$Structure <- mi_fit$Structure
}
results$SE <- mi_fit$SE
results$CI <- mi_fit$CI
# No per-parameter Rubin pooling on the MI2S path: the imputation uncertainty
# is carried by Gamma_tilde, not by element-wise between-imputation variance.
# The slots stay present-but-NULL so the schema matches the other pooled paths.
results["MI"] <- list(NULL)
results["replicates"] <- list(NULL)
# Same load-bearing legacy classes as the Rubin/bootstrap return above.
class(results) <- c("efa_mi", "EFA_POOLED", "efa", "EFA")
results
}
.efa_pooled_get_Ns <- function(data_list, fits, efa_args) {
# Recover the N used in each EFA fit. Correlation-matrix input may not carry N,
# so return NA unless N was supplied to EFA() or stored in settings.
vapply(seq_along(data_list), function(d) {
if (!is.null(fits[[d]]$settings$N) && !is.na(fits[[d]]$settings$N)) {
return(as.numeric(fits[[d]]$settings$N))
}
if (!is.null(efa_args$N) && !is.na(efa_args$N)) {
return(as.numeric(efa_args$N))
}
if (!.is_cormat(data_list[[d]])) {
return(nrow(data_list[[d]]))
}
NA_real_
}, numeric(1))
}
.efa_pooled_check_fits <- function(fits) {
# Fail early if the fitted EFA objects are not conformable. Pooling only makes
# sense when all imputations estimate the same model on the same variables. The
# arity of the list is already asserted on `data_list` by the caller.
dims <- vapply(fits, function(x) {
paste(dim(as.matrix(x$unrot_loadings)), collapse = "x")
}, character(1))
if (length(unique(dims)) != 1L) {
cli::cli_abort("All unrotated loading matrices must have the same dimensions.", class = "efa_pooled_dim_mismatch")
}
var_names <- lapply(fits, function(x) rownames(as.matrix(x$orig_R)))
if (!all(vapply(var_names[-1], identical, logical(1), var_names[[1]]))) {
cli::cli_abort("All imputations must contain the same variables in the same order.", class = "efa_pooled_var_mismatch")
}
for (nm in c("estimator", "rotation", "n_factors")) {
vals <- vapply(fits, .efa_pooled_setting_chr, character(1), name = nm)
vals <- vals[!is.na(vals)]
if (length(unique(vals)) > 1L) {
cli::cli_abort("All imputations must use the same {.arg {nm}}.", class = "efa_pooled_setting_mismatch")
}
}
invisible(TRUE)
}
Any scripts or data that you put into this service are public.
Add the following code to your website.
For more information on customizing the embed code, read Embedding Snippets.