R/efa_simulate.R

Defines functions .riwish .model_error_wb .model_error_tkl .model_error_cb .apply_model_error .apply_missing .calibrate_missing_intercept .resolve_missing_predictor .resolve_missing_vars .resolve_column_spec .ordinalize .vm_thresholds .vm_cut_mass .ordinal_thresholds .ig_generator_moments .vm_achieved_cor .vm_intermediate_cor .vm_cubic_coefs .fleishman_transform .fleishman_table .fleishman_coef .moment_vec .simulate_cfm_empirical .new_efa_simulated efa_simulate

Documented in efa_simulate

#' Simulate data from a common-factor population model
#'
#' Draws data from a population correlation matrix, given either directly or
#' built from a factor model. The population correlation is either supplied in
#' `R`, or assembled from a loading matrix `Lambda`, the factor intercorrelations
#' `Phi`, and the unique variances `Psi` as
#' \eqn{R = Lambda\, Phi\, Lambda' + Psi}, standardized to a correlation matrix.
#' `marginals` chooses the marginal distribution of the drawn cases, `categories`
#' discretizes them into ordered categories, `missing` imposes a missing-data mechanism,
#' and a misfit target perturbs the population with model error; see *Details*.
#'
#' @details
#' Provide the population either as a ready correlation matrix in `R`, or through
#' the model components `Lambda`, `Phi`, and `Psi`; the two ways are mutually
#' exclusive. When the model components are used, `Phi` defaults to the identity
#' matrix (orthogonal factors) and `Psi` defaults to the unique variances that
#' make the population a correlation matrix (\eqn{1 - \mathrm{diag}(Lambda\, Phi\,
#' Lambda')}); the assembled covariance is standardized with
#' [stats::cov2cor()] so a non-standardized `Psi` still yields a correlation
#' matrix. With the default `Psi`, a factor model whose implied communalities
#' exceed 1 (a Heywood case) leaves no unique variance and is rejected; a `Psi`
#' you supply is instead only required to give positive variances and a
#' positive-semidefinite (a mathematically valid, internally consistent
#' correlation/covariance structure) population. Cases with normal marginals (the
#' default) are drawn through a matrix square root of the population correlation --
#' a Cholesky factor, or a symmetric eigen square root when it is singular (e.g. a
#' communality of exactly 1).
#'
#' With `marginals = "empirical"`, the iterative rank-matching algorithm of Ruscio
#' and Kaczetow (2008) reproduces the population correlation while each variable
#' takes the empirical marginal distribution of the matching column of
#' `marginal_data` (resampled with replacement). Only the marginals of
#' `marginal_data` are used; its own correlations are ignored, and the drawn columns
#' follow the population's variables, not those of `marginal_data`.
#'
#' With `marginals = "VM"` (Vale-Maurelli, 1983) or `"IG"` (the independent-generator
#' method; Foldnes & Olsson, 2016), the cases reproduce the population correlation
#' while carrying non-normal marginals with the target `skewness` and (excess)
#' `kurtosis`. The Vale-Maurelli family does not span every valid non-normal
#' distribution (Foldnes & Grønneberg, 2015); `"IG"` covers distributions `"VM"`
#' cannot. Not every (`skewness`, `kurtosis`) pair is attainable -- every distribution
#' needs excess kurtosis of at least `skewness^2 - 2`, and either method covers a smaller
#' region still -- so an unreachable request is rejected, as is a `"VM"` intermediate
#' correlation matrix that is not positive definite unless `force_pd` allows it.
#'
#' With `categories`, the drawn data are discretized into ordered categories (an integer
#' code `1` to `K`) at the thresholds that reproduce the requested category proportions
#' (Olsson, 1979): the standard-normal quantiles for `marginals = "normal"`, and for
#' `marginals = "VM"` those quantiles mapped through the same Fleishman (1978) cubic the
#' draw uses, so the requested proportions are reproduced on the non-normal scale too. Under
#' `marginals = "IG"` the thresholds stay on the standard-normal scale while the data do
#' not, so the achieved proportions depart from the request systematically rather than by
#' sampling noise, and only the *number* of categories is guaranteed; the same holds for a
#' `"VM"` variable whose Fleishman cubic is not increasing over its own thresholds and the
#' tails beyond them, which keeps the normal-scale thresholds and is reported with a
#' warning. This is more likely with strong skewness/kurtosis or very unequal category
#' proportions; the warning names the affected variable.
#' Because categorization attenuates product-moment correlations, the categorized
#' data's Pearson correlation is smaller in magnitude than the population correlation;
#' under non-normal marginals its polychoric correlation departs from the population as
#' well. Ordinal output is not available with `marginals = "empirical"`. Empty categories
#' left by a draw are reported with a warning, as they destabilize the polychoric
#' correlation and the factor analysis.
#'
#' With `missing`, missing values are introduced into the drawn data under a chosen
#' mechanism (Rubin, 1976), each variable holed at a target expected rate
#' `missing_prop`. `"MCAR"` draws an independent mask, so missingness is unrelated to
#' the data. `"MAR"` and `"MNAR"` set each case's missing probability by a logistic
#' model of a standardized predictor: another variable for `"MAR"` (chosen by
#' `missing_predictor`) or the variable's own value for `"MNAR"`, with slope
#' `missing_strength`. The mechanism acts on the drawn (latent) values, so when
#' `categories` also discretizes the data the missingness is keyed on the underlying
#' value, not the category code. For `"MAR"` the predictor is evaluated on the complete
#' drawn values, so whether the mechanism is *ignorably* MAR depends on which variables
#' carry missing values. By default every variable is holed, which leaves a variable's MAR
#' predictor itself missing for roughly a `missing_prop` fraction of the cases whose
#' missingness it drove. This breaks ignorability: the mechanism is then MAR conditional
#' on the *complete* data but **not** ignorable for an analyst who sees only the observed
#' data. As a result, estimators that are consistent under ignorable MAR -- `cor_method =
#' "fiml"` in [efa_fit()], or the multiple imputation behind [efa_mi()] -- keep a residual
#' bias that grows with `missing_prop` and `missing_strength`. Restricting the holed
#' variables with `missing_vars` and pointing
#' `missing_predictor` at variables outside that set makes every predictor fully observed,
#' which is ignorably MAR and recovers the unbiasedness those estimators are advertised
#' with. The returned matrix carries the `NA`s, which the correlation estimators handle
#' downstream.
#'
#' With `model_error`, the population is perturbed away from the exact factor
#' structure so the `q`-factor model (`q = ncol(Lambda)`) fits it only approximately,
#' at a prescribed misfit; exact factor structures are unrealistic (see
#' `target_rmsea`). The perturbation is applied once to
#' the population, and the achieved misfit of the specified generating model is computed
#' with the same fit-index formulas [efa_fit()] uses and returned in the `model_error`
#' element. It needs a factor-model population with residual degrees of freedom and an
#' exact factor structure (a diagonal `Psi`), and is orthogonal to the marginal, ordinal,
#' and missing-data options. Three methods are available. `"CB"` (Cudeck & Browne,
#' 1992) matches the target RMSEA to numerical precision and keeps the `q`-factor
#' model the exact minimizer (the CFI follows as a derived quantity). `"TKL"` (Tucker,
#' Koopman & Linn, 1969) adds minor common factors tuned so the achieved RMSEA -- and,
#' optionally, CFI -- match the target(s); with a single target the match is close, with
#' both it is a compromise, reported with a warning when the two cannot be reconciled.
#' `"WB"` (Wu & Browne, 2015) draws the population from
#' an inverse-Wishart distribution around the model-implied correlation; its calibration
#' applies to the *best-fitting* model, so the reported misfit of the *generating* model
#' is systematically larger than the target -- about 1.4 times for a typical 12-variable,
#' 3-factor model. Use `"CB"` when the reported RMSEA must equal the target.
#'
#' Replicated draws (`n_datasets > 1`) are generated in parallel across
#' replicates with \pkg{future.apply}; a parallel plan can be selected with
#' [future::plan()] (the default plan runs sequentially). Each replicate is
#' assigned its own reproducible random-number stream, so with a fixed `seed` the
#' output is identical regardless of the number of workers.
#'
#' @param N numeric. Number of cases (rows) to draw per dataset. Required unless
#'   `return_pop = TRUE`.
#' @param Lambda matrix. A `p` by `m` matrix of factor loadings. Supply this
#'   (optionally with `Phi` and `Psi`) instead of `R` to build the population
#'   from a factor model.
#' @param Phi matrix. The `m` by `m` factor intercorrelation matrix. Only used
#'   with `Lambda`. Default is `NULL`, in which case the factors are orthogonal
#'   (an identity matrix).
#' @param Psi numeric vector or matrix. The unique variances: either a length-`p`
#'   vector or a `p` by `p` matrix (added as the residual covariance). Only used
#'   with `Lambda`. Default is `NULL`, in which case the unique variances that
#'   standardize the population to a correlation matrix are used.
#' @param R matrix. A `p` by `p` population correlation matrix to draw from
#'   directly. Supply this instead of `Lambda`/`Phi`/`Psi`. It must have a unit
#'   diagonal; standardize a covariance matrix with [stats::cov2cor()] first.
#' @param model_error character. The method used to perturb the population so the factor model
#'   fits it imperfectly ("model error"): one of `"CB"` (Cudeck-Browne, the default), `"TKL"`
#'   (Tucker-Koopman-Linn), `"WB"` (Wu-Browne), or `"none"`. Model error is only applied when a
#'   target is supplied in `target_rmsea` or `target_cfi`; without one the population is exact,
#'   whatever `model_error`. Only used with a factor-model population (`Lambda`). Note that
#'   [efa_power()] defaults to `"TKL"` instead, because its minor common factors degrade
#'   factor recovery as well as the fit, so passing the same target to the two functions
#'   perturbs the population differently unless `model_error` is set explicitly.
#' @param target_rmsea numeric. The population RMSEA the factor model should have relative to
#'   the perturbed population, a single number strictly in `(0, 1)`. Supplying it activates model
#'   error. Simulating from an exact population overstates recovery, so a realistic value (around
#'   `0.05`) is recommended for simulation studies (MacCallum, 2003). Default is `NULL` (an
#'   exact population; do not pass `0`). Required for `"CB"` and `"WB"`; optional for `"TKL"`.
#' @param target_cfi numeric. Only used with `model_error = "TKL"`: the population CFI to
#'   target, a single number strictly in `(0, 1)`, on its own or together with `target_rmsea`
#'   (TKL then trades the two off). Default is `NULL` (do not pass `1`). `"CB"` and `"WB"` target
#'   the RMSEA only.
#' @param marginals character. The marginal distribution of the drawn data: one
#'   of `"normal"` (the default), which draws normal marginals; `"empirical"`,
#'   which reproduces the population correlation while preserving the empirical
#'   marginals supplied in `marginal_data`; or `"VM"` (Vale-Maurelli) and `"IG"`
#'   (independent generator), which draw non-normal marginals with the target
#'   `skewness` and `kurtosis`.
#' @param marginal_data matrix or data frame. Only used with
#'   `marginals = "empirical"`, where it is required: a data set with one numeric
#'   column per variable (`p` columns), each with at least two distinct values,
#'   whose per-column distributions are resampled as the marginals of the drawn
#'   data. Its correlations are ignored. Default is `NULL`.
#' @param n_factors numeric. Only used with `marginals = "empirical"`: the number
#'   of factors the rank-matching reproduction fits. Default is `NULL`, in which
#'   case it is the number of columns of `Lambda` when the population is built
#'   from a factor model; it must be given when the population is supplied via `R`.
#' @param skewness numeric. Only used with `marginals = "VM"` or `"IG"`: the
#'   target marginal skewness, as a single value applied to every variable or a
#'   length-`p` vector. Default is `NULL` (0, a symmetric marginal). At least one
#'   of `skewness` or `kurtosis` must be given for these marginals.
#' @param kurtosis numeric. Only used with `marginals = "VM"` or `"IG"`: the
#'   target marginal excess kurtosis (0 for a normal marginal), as a single value
#'   applied to every variable or a length-`p` vector. Default is `NULL` (0).
#' @param force_pd logical. Used with `marginals = "VM"` and with Cudeck-Browne model error
#'   (`model_error = "CB"`), where `FALSE` (the default) rejects a population or intermediate
#'   matrix that is not positive definite. What `TRUE` accepts instead differs by path, in
#'   both cases with a warning: for `marginals = "VM"` the intermediate correlation matrix is
#'   projected to the nearest correlation matrix (via [psych::cor.smooth()]), and the returned
#'   `population` becomes the one the projected draw attains; for `model_error = "CB"` there is
#'   no such projection -- the closest positive-definite perturbation is kept instead, whose
#'   achieved RMSEA is **below** the target. Has no effect for the `"TKL"` or `"WB"` methods.
#' @param categories numeric or list. Requests ordinal output by discretizing each
#'   variable into ordered categories. Either a count of equally probable categories
#'   (a single value applied to every variable or a length-`p` vector), or a
#'   length-`p` list of numeric vectors giving the marginal category proportions per
#'   variable (each strictly positive and summing to 1). Default is `NULL`, which
#'   returns the continuous data.
#' @param match character. Only used with `categories`: an assertion about how the
#'   categorization relates to the population correlation. With a normal latent, cutting
#'   at the normal-scale thresholds already leaves the population polychoric correlation
#'   of the categorized data equal to the target correlation, so both values compute the
#'   same thresholds and produce identical data whenever both are legal. `"thresholds"`
#'   (the default) also cuts the `"VM"` and `"IG"` draws, whose ordinal Pearson and
#'   polychoric correlations then both depart from the population; `"polychoric"` states
#'   that the polychoric match is required and therefore rejects non-normal marginals.
#'   Not available with `marginals = "empirical"`. Default is `NULL`
#'   (`"thresholds"` when `categories` is set).
#' @param missing character. An optional missing-data mechanism to impose on the drawn
#'   data: one of `"none"` (the default, complete data), `"MCAR"` (missing completely at
#'   random), `"MAR"` (missing at random, depending on another variable), or `"MNAR"`
#'   (missing not at random, depending on the variable's own value). Introduced values
#'   become `NA`. By default every variable is holed, so under `"MAR"` each variable's
#'   predictor is itself subject to missingness and the mechanism is MAR given the
#'   *complete* data rather than ignorably MAR; `missing_vars` and `missing_predictor`
#'   together give an ignorable design (see Details).
#' @param missing_prop numeric. Only used when `missing` is not `"none"`, where it is
#'   required: the target marginal proportion of missing values per variable, a single
#'   number strictly between 0 and 1. This is the expected rate; the realized rate of a
#'   given draw varies around it.
#' @param missing_strength numeric. Only used with `missing = "MAR"` or `"MNAR"`: the
#'   slope of the logistic missingness model, setting how strongly the missing
#'   probability depends on the predictor. Default is `NULL` (1, a moderate dependence);
#'   `0` removes the dependence (equivalent to MCAR at the same rate) and large
#'   magnitudes make missingness nearly deterministic.
#' @param missing_predictor integer or character. Only used with `missing = "MAR"`:
#'   which variable drives each holed variable's missingness, as one column index or
#'   variable name per variable in `missing_vars` (so one per variable by default), in
#'   that order. A variable cannot be its own predictor; if the same predictor is reused
#'   for several `missing_vars`, it must not itself be one of them. Default is
#'   `NULL`, in which case each variable's missingness is driven by the next variable
#'   cyclically (so variable order matters; supply this explicitly when the order is
#'   arbitrary). Predictors outside `missing_vars` are fully observed (see Details for
#'   why this matters).
#' @param missing_vars integer or character. Only used when `missing` is not `"none"`:
#'   which variables carry missing values, as column indices or variable names. Default is
#'   `NULL`, in which case every variable is holed. Restricting it, together with
#'   pointing `missing_predictor` at variables outside that set, is how an ignorably
#'   MAR design is obtained (see Details).
#' @param n_datasets numeric. The number of datasets to draw. Default is 1. With
#'   more than one, a list of datasets is returned.
#' @param seed numeric. Optional seed for reproducible draws. When supplied, the
#'   caller's random-number stream is saved and restored, so the call leaves the
#'   global RNG state unchanged. Default is `NULL` (no seeding).
#' @param return_pop logical. If `TRUE`, return only the population correlation
#'   matrix and draw no data. Default is `FALSE`. Because no data are drawn, the
#'   Vale-Maurelli intermediate correlation matrix is never formed, so its
#'   positive-definiteness (and any `force_pd` projection of it) is neither checked nor
#'   reflected in the returned population.
#'
#' @returns An object of class `efa_simulated`, a list containing:
#' \item{data}{The simulated data: an `N` by `p` numeric matrix, an integer matrix of
#'   category codes when `categories` is set, or a length-`n_datasets` list of these when
#'   `n_datasets > 1`. `NULL` when `return_pop = TRUE`.}
#' \item{population}{The `p` by `p` population correlation matrix drawn from,
#'   model-error-perturbed when requested. When `force_pd = TRUE` causes `marginals =
#'   "VM"`'s intermediate matrix to be projected, `population` is the population the
#'   projected draw actually reaches, differing from the target by the drift the warning
#'   reports.}
#' \item{model_error}{`NULL`, or, when model error was applied, a list of the method, the
#'   target and achieved RMSEA/CFI, the model degrees of freedom `df`, and the method's
#'   tuning parameter: `kappa` for `"CB"`, `v` and `eps` for `"TKL"`, `m` for `"WB"`.}
#' \item{settings}{The call's key arguments (`N`, `n_datasets`, `marginals`, `categories`,
#'   `match`, `missing`, `seed`), kept for reference and used when printing.}
#'
#' Printing the object shows a compact summary.
#'
#' @references Cudeck, R., & Browne, M. W. (1992). Constructing a covariance matrix
#'   that yields a specified minimizer and a specified minimum discrepancy function
#'   value. Psychometrika, 57(3), 357-369. \doi{10.1007/BF02295424}
#'
#'   Fleishman, A. I. (1978). A method for simulating non-normal
#'   distributions. Psychometrika, 43(4), 521-532. \doi{10.1007/BF02293811}
#'
#'   Foldnes, N., & Grønneberg, S. (2015). How general is the Vale-Maurelli
#'   simulation approach? Psychometrika, 80(4), 1066-1083.
#'   \doi{10.1007/s11336-014-9414-0}
#'
#'   Foldnes, N., & Olsson, U. H. (2016). A simple simulation technique for
#'   nonnormal data with prespecified skewness, kurtosis, and covariance matrix.
#'   Multivariate Behavioral Research, 51(2-3), 207-219.
#'   \doi{10.1080/00273171.2015.1133274}
#'
#'   MacCallum, R. C. (2003). 2001 Presidential Address: Working with imperfect
#'   models. Multivariate Behavioral Research, 38(1), 113-139.
#'   \doi{10.1207/S15327906MBR3801_5}
#'
#'   Olsson, U. (1979). Maximum likelihood estimation of the polychoric correlation
#'   coefficient. Psychometrika, 44(4), 443-460. \doi{10.1007/BF02296207}
#'
#'   Rubin, D. B. (1976). Inference and missing data. Biometrika, 63(3), 581-592.
#'   \doi{10.1093/biomet/63.3.581}
#'
#'   Ruscio, J., & Kaczetow, W. (2008). Simulating multivariate nonnormal data
#'   using an iterative algorithm. Multivariate Behavioral Research, 43(3),
#'   355-381. \doi{10.1080/00273170802285693}
#'
#'   Tucker, L. R., Koopman, R. F., & Linn, R. L. (1969). Evaluation of factor
#'   analytic research procedures by means of simulated correlation matrices.
#'   Psychometrika, 34(4), 421-459. \doi{10.1007/BF02290601}
#'
#'   Vale, C. D., & Maurelli, V. A. (1983). Simulating multivariate nonnormal
#'   distributions. Psychometrika, 48(3), 465-471. \doi{10.1007/BF02293687}
#'
#'   Wu, H., & Browne, M. W. (2015). Quantifying adventitious error in a covariance
#'   structure as a random effect. Psychometrika, 80(3), 571-600.
#'   \doi{10.1007/s11336-015-9451-3}
#'
#' @seealso [efa_power()], whose simulation mode draws its replicate data sets with this
#'   function, and [efa_fit()] for analysing the simulated data.
#'
#' @family data simulation
#'
#' @export
#'
#' @examples
#' # Build a population from a shipped loading pattern and factor correlations
#' Lambda <- population_models$loadings$baseline
#' Phi <- population_models$phis_3$moderate
#'
#' # Draw one normal dataset of 500 cases (the data live in $data)
#' sim <- efa_simulate(N = 500, Lambda = Lambda, Phi = Phi, seed = 42)
#' dim(sim$data)
#'
#' # Return only the population correlation matrix
#' R_pop <- efa_simulate(Lambda = Lambda, Phi = Phi, return_pop = TRUE)$population
#'
#' # Draw several datasets at once from a supplied correlation matrix
#' sims <- efa_simulate(N = 500, R = R_pop, n_datasets = 3, seed = 42)
#' length(sims$data)
#'
#' # Reproduce the population correlation but with skewed, empirical marginals
#' # (here from a chi-squared source with one column per variable)
#' src <- matrix(rchisq(200 * nrow(Lambda), df = 3), ncol = nrow(Lambda))
#' dat_emp <- efa_simulate(N = 500, Lambda = Lambda, Phi = Phi,
#'                         marginals = "empirical", marginal_data = src, seed = 42)
#'
#' # Draw skewed, leptokurtic data with the Vale-Maurelli method
#' dat_vm <- efa_simulate(N = 500, Lambda = Lambda, Phi = Phi, marginals = "VM",
#'                        skewness = 1.5, kurtosis = 4, seed = 42)
#'
#' # Draw five-category ordinal data whose polychoric correlation matches R
#' dat_ord <- efa_simulate(N = 500, Lambda = Lambda, Phi = Phi,
#'                         categories = 5, match = "polychoric", seed = 42)
#'
#' # Draw data with 15% missing at random, driven by a neighbouring item
#' dat_mar <- efa_simulate(N = 500, Lambda = Lambda, Phi = Phi, missing = "MAR",
#'                         missing_prop = 0.15, seed = 42)
#' colMeans(is.na(dat_mar$data))
#'
#' # An ignorably MAR design: only the first nine items are holed, each driven by one of
#' # the last nine, which stay complete
#' dat_ign <- efa_simulate(N = 500, Lambda = Lambda, Phi = Phi, missing = "MAR",
#'                         missing_prop = 0.15, missing_vars = 1:9,
#'                         missing_predictor = 10:18, seed = 42)
#' colMeans(is.na(dat_ign$data))
#'
#' # Add realistic model error: a population the model fits with RMSEA of about .05
#' # (Cudeck-Browne, the default method; the achieved fit is reported)
#' sim_me <- efa_simulate(N = 500, Lambda = Lambda, Phi = Phi,
#'                        target_rmsea = 0.05, seed = 42)
#' sim_me$model_error$rmsea
efa_simulate <- function(N = NULL, Lambda = NULL, Phi = NULL, Psi = NULL,
                         R = NULL,
                         model_error = c("CB", "TKL", "WB", "none"),
                         target_rmsea = NULL, target_cfi = NULL,
                         marginals = c("normal", "empirical", "VM", "IG"),
                         marginal_data = NULL, n_factors = NULL,
                         skewness = NULL, kurtosis = NULL, force_pd = FALSE,
                         categories = NULL, match = NULL,
                         missing = c("none", "MCAR", "MAR", "MNAR"),
                         missing_prop = NULL, missing_strength = NULL,
                         missing_predictor = NULL, missing_vars = NULL,
                         n_datasets = 1L, seed = NULL, return_pop = FALSE) {

  # Eigenvalues below -tol mark the population as indefinite (not a valid
  # covariance); kept equal to the compiled core's default so both agree.
  tol <- 1e-8

  checkmate::assert_flag(return_pop)
  checkmate::assert_flag(force_pd)
  checkmate::assert_count(n_datasets, positive = TRUE)
  checkmate::assert_int(seed, null.ok = TRUE)

  marginals <- .match_arg_ci(marginals)

  # `model_error` selects the method used to perturb the population so a factor model fits it
  # imperfectly; `target_rmsea`/`target_cfi` set the amount and activate it. A target is what
  # turns model error on -- without one the population is exact, whatever `model_error`. CB and
  # WB target the RMSEA only; TKL can target the RMSEA and/or the CFI. Compatibility is checked
  # here; the perturbation itself runs once the population is built (below).
  model_error <- .match_arg_ci(model_error)
  # The targets are strictly inside (0, 1): a target RMSEA of 0 (or CFI of 1) means no misfit,
  # which is the exact population -- omit the target instead of asking a model-error method to
  # inject zero error (which would only degenerate the solvers).
  if (!is.null(target_rmsea) &&
      (!checkmate::test_number(target_rmsea) || target_rmsea <= 0 || target_rmsea >= 1)) {
    cli::cli_abort(
      c("{.arg target_rmsea} must be a single number in (0, 1).",
        "i" = "Omit {.arg target_rmsea} for an exact (zero-misfit) population."),
      class = "efa_simulate_input")
  }
  if (!is.null(target_cfi) &&
      (!checkmate::test_number(target_cfi) || target_cfi <= 0 || target_cfi >= 1)) {
    cli::cli_abort(
      c("{.arg target_cfi} must be a single number in (0, 1).",
        "i" = "Omit {.arg target_cfi} for an exact (perfect-fit) population."),
      class = "efa_simulate_input")
  }
  have_target <- !is.null(target_rmsea) || !is.null(target_cfi)
  do_model_error <- model_error != "none" && have_target
  if (model_error == "none" && have_target) {
    cli::cli_abort(
      c("{.arg target_rmsea} and {.arg target_cfi} require a model-error method.",
        "x" = "You supplied a target but left {.code model_error = \"none\"}.",
        "i" = "Choose {.code model_error = \"CB\"}, {.val TKL}, or {.val WB}."),
      class = "efa_simulate_input"
    )
  }
  # CB and WB target the RMSEA only.
  if (do_model_error && model_error %in% c("CB", "WB")) {
    if (is.null(target_rmsea)) {
      cli::cli_abort(
        c("{.code model_error = \"{model_error}\"} requires {.arg target_rmsea}.",
          "i" = "{.val CB} and {.val WB} target the RMSEA."),
        class = "efa_simulate_input"
      )
    }
    if (!is.null(target_cfi)) {
      cli::cli_abort(
        c("{.code model_error = \"{model_error}\"} targets the RMSEA only.",
          "x" = "You also supplied {.arg target_cfi}.",
          "i" = "Use {.code model_error = \"TKL\"} to target the CFI (with or without the RMSEA)."),
        class = "efa_simulate_input"
      )
    }
  }

  # marginal_data and n_factors configure the empirical rank-matching path only;
  # skewness and kurtosis configure the non-normal VM/IG paths, and force_pd the
  # Vale-Maurelli path only.
  if (marginals != "empirical" && (!is.null(marginal_data) || !is.null(n_factors))) {
    supplied <- c("marginal_data", "n_factors")[c(!is.null(marginal_data),
                                                  !is.null(n_factors))]
    cli::cli_abort(
      c("{.arg marginal_data} and {.arg n_factors} only apply when {.code marginals = \"empirical\"}.",
        "x" = "You set {.code marginals = \"{marginals}\"} but supplied {.arg {supplied}}."),
      class = "efa_simulate_input"
    )
  }
  if (!(marginals %in% c("VM", "IG")) &&
      (!is.null(skewness) || !is.null(kurtosis))) {
    supplied <- c("skewness", "kurtosis")[c(!is.null(skewness), !is.null(kurtosis))]
    cli::cli_abort(
      c("{.arg skewness} and {.arg kurtosis} only apply when {.code marginals} is {.val VM} or {.val IG}.",
        "x" = "You set {.code marginals = \"{marginals}\"} but supplied {.arg {supplied}}."),
      class = "efa_simulate_input"
    )
  }
  # force_pd rescues the Vale-Maurelli intermediate correlation matrix and, for Cudeck-Browne
  # model error, a population that cannot reach the target RMSEA while staying positive definite;
  # it has no role otherwise (the IG path needs a positive definite population outright).
  if (marginals != "VM" && !do_model_error && isTRUE(force_pd)) {
    cli::cli_abort(
      c("{.arg force_pd} only applies when {.code marginals = \"VM\"} or model error is requested.",
        "x" = "You set {.code force_pd = TRUE} but {.code marginals = \"{marginals}\"} with no model-error target."),
      class = "efa_simulate_input"
    )
  }

  # `categories` requests ordered-category (ordinal) output; `match` selects how
  # that categorization relates to the population correlation and is meaningless
  # without it.
  if (is.null(categories)) {
    if (!is.null(match)) {
      cli::cli_abort(
        c("{.arg match} only applies when {.arg categories} requests ordinal output.",
          "x" = "You set {.arg match} but left {.arg categories} unset, so continuous data are returned."),
        class = "efa_simulate_input"
      )
    }
  } else {
    match <- .match_arg_ci(match, c("thresholds", "polychoric"))
    # The polychoric correlation is defined by a bivariate-normal latent, so it can
    # only equal the target correlation when the latent is normal. Pairing it with
    # non-normal marginals is a contradiction rather than an approximation.
    if (match == "polychoric" && marginals != "normal") {
      cli::cli_abort(
        c("{.code match = \"polychoric\"} requires normal latents, but {.code marginals = \"{marginals}\"} draws non-normal marginals.",
          "x" = "The polychoric correlation assumes a bivariate-normal latent, so it cannot equal the target correlation under non-normal marginals.",
          "i" = "Use {.code match = \"thresholds\"} to cut the non-normal data (its ordinal Pearson correlation is then attenuated), or keep {.code marginals = \"normal\"}."),
        class = "efa_simulate_match_conflict"
      )
    }
    # Thresholds sit on the standard-normal scale, which categorizes the standardized
    # draws (normal, and the unit-variance VM/IG marginals) as intended. The empirical
    # draw instead carries the arbitrary location and scale of `marginal_data`, so
    # those thresholds would not reproduce the requested categories.
    if (marginals == "empirical") {
      cli::cli_abort(
        c("{.arg categories} is not supported with {.code marginals = \"empirical\"}.",
          "x" = "Ordinal thresholds sit on the standard-normal scale, but the empirical draw keeps the arbitrary scale of {.arg marginal_data}, so the requested categories are not reproduced.",
          "i" = "Use {.code marginals = \"normal\"}, {.val VM}, or {.val IG} to draw ordinal data with {.arg categories}."),
        class = "efa_simulate_input"
      )
    }
  }

  # `missing` selects an optional missing-data mechanism; `missing_prop` sets the
  # target marginal missing rate, `missing_strength` the strength of the value
  # dependence in MAR/MNAR, `missing_predictor` the MAR predictor, and `missing_vars`
  # which variables are holed at all. Each companion is meaningful only under the
  # mechanisms that use it (compatibility is checked here; value validation runs later,
  # once the data dimension is known).
  missing <- .match_arg_ci(missing)
  if (missing == "none") {
    supplied <- c("missing_prop", "missing_strength", "missing_predictor",
                  "missing_vars")[
      c(!is.null(missing_prop), !is.null(missing_strength),
        !is.null(missing_predictor), !is.null(missing_vars))]
    if (length(supplied)) {
      cli::cli_abort(
        c("Missing-data settings only apply when {.arg missing} requests a mechanism.",
          "x" = "You supplied {.arg {supplied}} but left {.code missing = \"none\"}."),
        class = "efa_simulate_input"
      )
    }
  } else {
    if (is.null(missing_prop)) {
      cli::cli_abort(
        c("{.arg missing_prop} is required when {.arg missing} is not {.val none}.",
          "i" = "Set the target marginal proportion of missing values, e.g. {.code missing_prop = 0.1}."),
        class = "efa_simulate_input"
      )
    }
    # MCAR removes values independently of the data, so it has no strength or predictor;
    # MNAR's predictor is fixed to each variable's own value, so none may be supplied.
    if (missing == "MCAR" &&
        (!is.null(missing_strength) || !is.null(missing_predictor))) {
      supplied <- c("missing_strength", "missing_predictor")[
        c(!is.null(missing_strength), !is.null(missing_predictor))]
      cli::cli_abort(
        c("{.code missing = \"MCAR\"} removes values completely at random.",
          "x" = "You supplied {.arg {supplied}}, but MCAR has no predictor to depend on."),
        class = "efa_simulate_input"
      )
    }
    if (missing == "MNAR" && !is.null(missing_predictor)) {
      cli::cli_abort(
        c("{.arg missing_predictor} does not apply when {.code missing = \"MNAR\"}.",
          "x" = "MNAR makes each variable's missingness depend on its own value, not another predictor."),
        class = "efa_simulate_input"
      )
    }
  }

  # The population is given either as a ready correlation matrix or as model
  # components, never both and never neither.
  have_lambda <- !is.null(Lambda)
  have_R <- !is.null(R)
  if (have_lambda == have_R) {
    cli::cli_abort(
      c("Specify the population in exactly one way.",
        "x" = "Provide either {.arg R}, or {.arg Lambda} (with optional {.arg Phi}/{.arg Psi})."),
      class = "efa_simulate_input"
    )
  }
  if (have_R && (!is.null(Phi) || !is.null(Psi))) {
    cli::cli_abort(
      c("{.arg Phi} and {.arg Psi} only apply when the population is built from {.arg Lambda}.",
        "x" = "You supplied {.arg R} together with {.arg Phi}/{.arg Psi}."),
      class = "efa_simulate_input"
    )
  }

  if (have_lambda) {

    Lambda <- as.matrix(Lambda)
    checkmate::assert_matrix(Lambda, mode = "numeric", any.missing = FALSE,
                             min.rows = 1, min.cols = 1)
    # assert_matrix's any.missing rejects NA/NaN but not Inf; a non-finite loading
    # would propagate NaN into the communalities and turn the Heywood guard below
    # into an uninformative `if (NA)` error, so reject it up front.
    if (!all(is.finite(Lambda))) {
      cli::cli_abort("{.arg Lambda} must contain only finite values.",
                     class = "efa_simulate_input")
    }
    p <- nrow(Lambda)
    m <- ncol(Lambda)

    if (is.null(Phi)) {
      Phi <- diag(m)
    } else {
      Phi <- as.matrix(Phi)
      checkmate::assert_matrix(Phi, mode = "numeric", nrows = m, ncols = m,
                               any.missing = FALSE)
      if (!isSymmetric(unname(Phi))) {
        cli::cli_abort(c("{.arg Phi} must be a symmetric {m} by {m} matrix."),
                       class = "efa_simulate_input")
      }
    }

    common <- Lambda %*% Phi %*% t(Lambda)

    if (is.null(Psi)) {
      # Unique variances that standardize the population to a correlation matrix.
      # An implied communality above 1 (a Heywood case) leaves no unique variance
      # to simulate from; a communality of exactly 1 gives a drawable, if singular,
      # population and is allowed.
      psi_vec <- 1 - diag(common)
      if (any(psi_vec < 0)) {
        cli::cli_abort(
          c("The factor model implies a communality above 1 (a Heywood case).",
            "x" = "There is no unique variance left to simulate from.",
            "i" = "Provide unique variances in {.arg Psi}, or reduce the loadings."),
          class = "efa_simulate_heywood"
        )
      }
      Psi_mat <- diag(psi_vec, p)
    } else if (is.matrix(Psi)) {
      checkmate::assert_matrix(Psi, mode = "numeric", nrows = p, ncols = p,
                               any.missing = FALSE)
      # A residual covariance must be finite and symmetric; a non-finite or
      # non-symmetric matrix would otherwise be silently averaged into shape by the
      # symmetrization below (as Phi and R are also checked to be symmetric).
      if (!all(is.finite(Psi)) || !isSymmetric(unname(Psi))) {
        cli::cli_abort("{.arg Psi} must be a finite, symmetric {p} by {p} matrix.",
                       class = "efa_simulate_input")
      }
      Psi_mat <- Psi
    } else {
      checkmate::assert_numeric(Psi, len = p, any.missing = FALSE, finite = TRUE)
      Psi_mat <- diag(Psi, p)
    }

    Sigma <- common + Psi_mat
    if (any(diag(Sigma) <= 0)) {
      cli::cli_abort(
        c("The assembled population has a non-positive variance and cannot be standardized.",
          "x" = "Check {.arg Lambda}, {.arg Phi}, and {.arg Psi}."),
        class = "efa_simulate_heywood"
      )
    }
    R_pop <- stats::cov2cor(Sigma)
    var_names <- rownames(Lambda)

  } else {

    R <- as.matrix(R)
    checkmate::assert_matrix(R, mode = "numeric", any.missing = FALSE,
                             min.rows = 1)
    # `any.missing` does not reject Inf, so check finiteness explicitly; otherwise a
    # non-finite R reaches eigen() below and aborts with an unclassed base error.
    if (nrow(R) != ncol(R) || !all(is.finite(R)) || !isSymmetric(unname(R))) {
      cli::cli_abort(
        c("{.arg R} must be a square, symmetric correlation matrix with finite values."),
        class = "efa_simulate_input")
    }
    p <- nrow(R)
    # Ordinal thresholds, the Vale-Maurelli cubic, and the `match = "polychoric"`
    # assertion all live on the standard-normal scale, so a non-unit diagonal is not a
    # rescaling of the requested population but a different one: the drawn variables
    # would keep the supplied variances while the categorization assumes unit variance.
    # Aborting keeps that visible; silently standardizing would hide the mistake.
    diag_dev <- max(abs(diag(R) - 1))
    if (diag_dev > tol) {
      cli::cli_abort(
        c("{.arg R} must be a correlation matrix, with a unit diagonal.",
          "x" = "Its largest deviation of a diagonal element from 1 is {.val {diag_dev}}.",
          "i" = "Standardize a covariance matrix with {.fun stats::cov2cor} first."),
        class = "efa_simulate_input")
    }
    R_pop <- R
    # Variable names live on either dimension of a correlation matrix; fall back to
    # the column names when only those are present.
    var_names <- rownames(R)
    if (is.null(var_names)) var_names <- colnames(R)

  }

  # Remove any floating-point asymmetry so the matrix square root sees an exactly
  # symmetric matrix, and attach variable names.
  R_pop <- (R_pop + t(R_pop)) / 2
  if (is.null(var_names)) var_names <- paste0("V", seq_len(p))
  dimnames(R_pop) <- list(var_names, var_names)

  # Reject an indefinite population: a matrix with a markedly negative eigenvalue
  # is not a valid covariance to simulate from. This runs before the `return_pop`
  # exit too, so `return_pop = TRUE` only ever returns a valid correlation matrix.
  # A positive-semidefinite but singular population (smallest eigenvalue in
  # [-tol, 0]) is allowed and handled by the compiled core's eigen square-root
  # fallback.
  min_ev <- min(eigen(R_pop, symmetric = TRUE, only.values = TRUE)$values)
  if (min_ev < -tol) {
    cli::cli_abort(
      c("The population correlation matrix is not positive semi-definite.",
        "x" = "Its smallest eigenvalue is {.val {min_ev}}.",
        "i" = "A valid population model must have a positive semi-definite correlation matrix."),
      class = "efa_simulate_not_pd"
    )
  }

  # A fixed seed makes the whole call reproducible and independent of the number of parallel
  # workers. The caller's RNG stream is saved and restored afterwards -- or, if none existed yet,
  # the stream set.seed() creates is removed again -- so efa_simulate() leaves no side effect on
  # it (mirrors EFA()'s bootstrap seeding). Seeding here (before the optional model-error draw and
  # before the replicate loop) puts both under one umbrella; the model-error draw runs once in the
  # main process, and nothing between it and the replicate loop consumes random numbers, so a call
  # without model error draws the identical stream it did before this argument existed.
  .set_local_seed(seed)

  # Inject model error: perturb the population so the q-factor model fits it with the target
  # RMSEA (CB/WB) or RMSEA and/or CFI (TKL), and record the achieved fit. Requires the factor-model
  # population (there is no specified model to misfit against a bare `R`, and CB needs the model
  # Jacobian) and residual degrees of freedom. Runs once here so the perturbed population feeds the
  # `return_pop` exit, the VM/IG setup, and every replicate.
  model_error_info <- NULL
  if (do_model_error) {
    if (!have_lambda) {
      cli::cli_abort(
        c("Model error requires a factor-model population.",
          "x" = "You supplied {.arg R}, so there is no factor model to misfit against.",
          "i" = "Provide {.arg Lambda} (with optional {.arg Phi}/{.arg Psi}) to use {.arg model_error}."),
        class = "efa_simulate_input"
      )
    }
    q_me <- ncol(Lambda)
    if (.efa_df(p, q_me) <= 0) {
      cli::cli_abort(
        c("The {q_me}-factor model has no residual degrees of freedom, so no model error can be injected.",
          "i" = "Model error needs {.code ((p - q)^2 - (p + q)) / 2 > 0}."),
        class = "efa_simulate_input"
      )
    }
    # All three methods invert the population (solve()/chol()); a positive-semidefinite but
    # singular population (e.g. a communality of exactly 1) has no inverse, so require it to be
    # positive definite here (min_ev is the smallest eigenvalue computed above) rather than let a
    # base-R linear-algebra error escape unclassed.
    if (min_ev < tol) {
      cli::cli_abort(
        c("Model error requires a positive-definite population correlation matrix.",
          "x" = "Its smallest eigenvalue is {.val {min_ev}} (the population is singular or near-singular).",
          "i" = "Reduce the communalities (a communality of 1 makes the population singular), or omit the model-error target."),
        class = "efa_simulate_model_error"
      )
    }
    me <- .apply_model_error(R_pop, common, Sigma, q_me, model_error,
                            target_rmsea, target_cfi, force_pd, tol)
    R_pop <- (me$population + t(me$population)) / 2
    dimnames(R_pop) <- list(var_names, var_names)
    model_error_info <- me$model_error
    # The perturbed population must still be a valid (positive semi-definite) correlation matrix.
    min_ev <- min(eigen(R_pop, symmetric = TRUE, only.values = TRUE)$values)
    if (min_ev < -tol) {
      cli::cli_abort(
        c("The model-error population correlation matrix is not positive semi-definite.",
          "x" = "Its smallest eigenvalue is {.val {min_ev}}.",
          "i" = "Lower the target RMSEA/CFI, or use a different {.arg model_error} method."),
        class = "efa_simulate_not_pd"
      )
    }
  }

  if (return_pop) {
    return(.new_efa_simulated(
      data = NULL, population = R_pop, model_error = model_error_info,
      N = NA_integer_, n_datasets = NA_integer_, marginals = marginals,
      categories = categories, match = match, missing = missing, seed = seed))
  }

  checkmate::assert_count(N, positive = TRUE)

  # The empirical path needs a marginal source and a factor count for the
  # rank-matching reproduction; validate both against the population dimension.
  if (marginals == "empirical") {
    # The reproduction sorts and correlates the drawn cases, which needs at least
    # two of them: a single case has no correlation and collapses the working
    # matrix to a vector.
    if (N < 2L) {
      cli::cli_abort(
        c("{.arg N} must be at least 2 when {.code marginals = \"empirical\"}.",
          "x" = "You requested {N} case{?s}."),
        class = "efa_simulate_input"
      )
    }
    if (is.null(marginal_data)) {
      cli::cli_abort(
        c("{.arg marginal_data} is required when {.code marginals = \"empirical\"}.",
          "i" = "Supply a matrix or data frame with {p} column{?s} giving the empirical marginals."),
        class = "efa_simulate_input"
      )
    }
    marginal_data <- as.matrix(marginal_data)
    checkmate::assert_matrix(marginal_data, mode = "numeric", any.missing = FALSE,
                             min.rows = 1)
    # any.missing = FALSE rejects NA/NaN but not Inf, so check finiteness too.
    if (!all(is.finite(marginal_data))) {
      cli::cli_abort("{.arg marginal_data} must contain only finite values.",
                     class = "efa_simulate_input")
    }
    if (ncol(marginal_data) != p) {
      cli::cli_abort(
        c("{.arg marginal_data} must have one column per variable ({p}).",
          "x" = "It has {ncol(marginal_data)} column{?s}."),
        class = "efa_simulate_input"
      )
    }
    # Each column must offer at least two distinct values: a constant column is
    # not a distribution to resample and cannot reproduce a correlation (its
    # sample correlation is undefined), and a single-row source would resample a
    # length-one vector -- both of which yield silently wrong or degenerate draws.
    n_distinct <- apply(marginal_data, 2L, function(col) length(unique(col)))
    if (any(n_distinct < 2L)) {
      bad <- as.character(which(n_distinct < 2L))
      cli::cli_abort(
        c("Every column of {.arg marginal_data} must have at least two distinct values.",
          "x" = "{cli::qty(bad)}Constant column{?s}: {bad}."),
        class = "efa_simulate_input"
      )
    }

    # Default the reproduction's factor count to the model's when the population
    # was built from Lambda; a supplied R carries no such count, so require it.
    if (is.null(n_factors)) {
      if (have_lambda) {
        n_factors <- ncol(Lambda)
      } else {
        cli::cli_abort(
          c("{.arg n_factors} is required when the population is supplied via {.arg R}.",
            "i" = "It sets the factor count of the rank-matching reproduction."),
          class = "efa_simulate_input"
        )
      }
    }
    checkmate::assert_count(n_factors, positive = TRUE)
    # The iterative principal-axis step extracts n_factors factors from a p by p
    # matrix, which requires n_factors < p.
    if (n_factors >= p) {
      cli::cli_abort(
        c("{.arg n_factors} must be smaller than the number of variables ({p}).",
          "x" = "The reproduction uses {n_factors} factor{?s} for {p} variable{?s}.",
          "i" = "When not supplied, {.arg n_factors} defaults to the number of columns of {.arg Lambda}."),
        class = "efa_simulate_input"
      )
    }
  }

  # The VM/IG non-normal paths need target marginal moments; validate them,
  # recycle to length p, and precompute the Fleishman transform once. None of the
  # solves below draw random numbers, so they live outside the replicate loop.
  vm_ftable <- vm_Rinter <- ig_ftable <- ig_L <- ig_Lt <- NULL
  kurt_default <- FALSE
  if (marginals %in% c("VM", "IG")) {
    if (is.null(skewness) && is.null(kurtosis)) {
      cli::cli_abort(
        c("{.arg skewness} and/or {.arg kurtosis} are required when {.code marginals} is {.val VM} or {.val IG}.",
          "i" = "The unset one defaults to 0 (the normal-marginal value)."),
        class = "efa_simulate_input"
      )
    }
    # Recorded before the recycling below turns an unset moment into zeros, so an
    # infeasible-moment abort can name the default as the likely cause.
    kurt_default <- is.null(kurtosis)
    skewness <- .moment_vec(skewness, p, "skewness")
    kurtosis <- .moment_vec(kurtosis, p, "kurtosis")
    # Universal moment bound (Pearson, 1916): every distribution has excess
    # kurtosis at least skewness^2 - 2. Fleishman's cubic covers a smaller region
    # still, which the per-variable coefficient solve screens.
    infeasible <- kurtosis < skewness^2 - 2
    if (any(infeasible)) {
      bad <- as.character(which(infeasible))
      cli::cli_abort(
        c("The requested marginal moments are not attainable by any distribution.",
          "x" = "{cli::qty(bad)}Variable{?s} {bad}: excess {.arg kurtosis} is below {.arg skewness}^2 - 2.",
          "i" = "Every distribution must have excess kurtosis >= skewness^2 - 2 (Pearson, 1916)."),
        class = "efa_simulate_infeasible_moments"
      )
    }
  }

  if (marginals == "VM") {
    # Vale-Maurelli (1983): draw normal deviates at an intermediate correlation
    # chosen so the per-variable Fleishman cubic reproduces the target correlation.
    vm_ftable <- .fleishman_table(skewness, kurtosis, kurt_default)
    vm_Rinter <- .vm_intermediate_cor(R_pop, vm_ftable)
    # The pairwise cubic solves ignore joint positive-definiteness, so the
    # intermediate matrix can be indefinite even when R_pop is not. Reject it, or
    # project it to the nearest correlation matrix on request.
    min_ev_i <- min(eigen(vm_Rinter, symmetric = TRUE, only.values = TRUE)$values)
    if (min_ev_i < -tol) {
      if (!force_pd) {
        cli::cli_abort(
          c("The Vale-Maurelli intermediate correlation matrix is not positive definite.",
            "x" = "Its smallest eigenvalue is {.val {min_ev_i}}.",
            "i" = "Set {.code force_pd = TRUE} to project it to the nearest correlation matrix, or lower the target correlations or non-normality."),
          class = "efa_simulate_intermediate_not_pd"
        )
      }
      # Muffle cor.smooth's routine note; re-surface a single classed warning.
      vm_Rinter <- withCallingHandlers(
        psych::cor.smooth(vm_Rinter),
        warning = function(w) {
          if (grepl("smoothing was done", conditionMessage(w), fixed = TRUE)) {
            invokeRestart("muffleWarning")
          }
        }
      )
      vm_Rinter <- (vm_Rinter + t(vm_Rinter)) / 2
      # The projected intermediate matrix no longer solves the Vale-Maurelli cubic, so the
      # data are drawn from a population other than the target. Push the projected
      # intermediate correlations back through that same cubic to obtain the population
      # actually drawn from in closed form (no simulation), and report it in `population`
      # so a recovery or bias study measures against the right target.
      R_ach <- .vm_achieved_cor(vm_Rinter, vm_ftable)
      drift <- max(abs(R_ach - R_pop)[lower.tri(R_pop)])
      R_pop <- R_ach
      dimnames(R_pop) <- list(var_names, var_names)
      cli::cli_warn(
        c("The Vale-Maurelli intermediate correlation matrix was not positive definite; it has been projected to the nearest correlation matrix.",
          "i" = "Projection was applied via {.fun psych::cor.smooth}; the population drawn from therefore drifts from the target by up to {.val {round(drift, 4)}} (largest absolute correlation difference).",
          "i" = "The returned {.field population} is the population actually drawn from, not the target."),
        class = "efa_simulate_pd_forced"
      )
    }

  } else if (marginals == "IG") {
    # Independent generator (Foldnes & Olsson, 2016): X = L Z with L the lower
    # Cholesky factor of R_pop and Z independent non-normal generators.
    ig_L <- tryCatch(t(chol(R_pop)), error = function(e) NULL)
    if (is.null(ig_L)) {
      cli::cli_abort(
        c("{.code marginals = \"IG\"} needs a positive definite population correlation matrix.",
          "x" = "The population is singular, so it has no Cholesky factor.",
          "i" = "Use {.code marginals = \"VM\"} or {.code marginals = \"empirical\"} for a singular population."),
        class = "efa_simulate_input"
      )
    }
    # Cumulant additivity turns the target marginal moments into the independent
    # generators' moments via lower-triangular solves; these depend on the
    # population, so a feasible request can still imply an infeasible generator.
    gen <- .ig_generator_moments(ig_L, skewness, kurtosis)
    # Report generator infeasibility in terms of the request the user made -- the
    # implied generator moments below are internal and not directly controllable.
    # Fleishman's region is tighter than the universal bound, so also translate a
    # solve failure (raised inside .fleishman_table) into this same message.
    ig_infeasible <- function() {
      cli::cli_abort(
        c("No independent generator can produce the requested marginals from this population.",
          "x" = "The moments the generators would need are not attainable.",
          "i" = "Lower the target correlations or the non-normality, or use {.code marginals = \"VM\"}."),
        class = "efa_simulate_infeasible_moments"
      )
    }
    if (any(gen$kurtosis < gen$skewness^2 - 2)) ig_infeasible()
    ig_ftable <- tryCatch(
      .fleishman_table(gen$skewness, gen$kurtosis),
      efa_simulate_infeasible_moments = function(e) ig_infeasible()
    )
    # Transpose of the Cholesky factor for the X = Z L' mixing; loop-invariant, so
    # precompute it once here rather than re-forming it in every replicate.
    ig_Lt <- t(ig_L)
  }

  # Resolve and validate the missing-data settings once (they draw no random numbers).
  # The mask itself is applied per replicate inside the draw so it shares each
  # replicate's reproducible stream. `miss_b` is the logistic slope (MAR/MNAR),
  # `miss_vars` the columns to hole, and `pred_idx` their MAR predictor columns; all
  # stay NULL when unused.
  miss_b <- NULL
  pred_idx <- NULL
  miss_vars <- NULL
  if (missing != "none") {
    # A proportion strictly inside (0, 1): 0 or 1 is not a mechanism to calibrate, and
    # the boundary would send qlogis() to +-Inf and break the intercept root-find.
    if (!checkmate::test_number(missing_prop) ||
        missing_prop <= 0 || missing_prop >= 1) {
      cli::cli_abort(
        c("{.arg missing_prop} must be a single number strictly between 0 and 1.",
          "x" = "It is the target marginal proportion of missing values."),
        class = "efa_simulate_input"
      )
    }
    if (missing %in% c("MAR", "MNAR")) {
      # The slope defaults to 1 (a moderate dependence); 0 removes the dependence, which
      # is MCAR at the same rate; large magnitudes make missingness near-deterministic.
      miss_b <- if (is.null(missing_strength)) 1 else missing_strength
      if (!checkmate::test_number(miss_b, finite = TRUE)) {
        cli::cli_abort(
          c("{.arg missing_strength} must be a single finite number.",
            "i" = "It is the slope of the logistic missingness model; larger magnitudes make missingness depend more strongly on the predictor."),
          class = "efa_simulate_input"
        )
      }
    }
    miss_vars <- .resolve_missing_vars(missing_vars, p, var_names)
    if (missing == "MAR") {
      pred_idx <- .resolve_missing_predictor(missing_predictor, p, var_names,
                                            miss_vars)
    }
  }

  # With `categories`, the drawn data are cut into ordered categories at thresholds
  # on the standard-normal scale. The thresholds draw no random numbers and are the
  # same for every replicate, so they are built once here; the cutting itself runs
  # after the draw (below), keeping the replicate RNG streams untouched.
  ord_thresholds <- if (!is.null(categories)) {
    .ordinal_thresholds(categories, p)
  }
  # The Vale-Maurelli draw is a cubic map of a standard normal, so the normal-scale
  # thresholds would cut it at the wrong quantiles; push them through the same cubic
  # so the requested proportions are reproduced on the non-normal scale. A variable
  # whose cubic does not increase across its own thresholds, or folds tail mass back
  # below an outermost cut, cannot be mapped faithfully and keeps the normal ones.
  if (!is.null(ord_thresholds) && marginals == "VM") {
    mapped <- .vm_thresholds(ord_thresholds, vm_ftable)
    ord_thresholds <- mapped$thresholds
    if (length(mapped$non_monotone)) {
      kept <- var_names[mapped$non_monotone]
      cli::cli_warn(
        c("{cli::qty(kept)}Variable{?s} {.val {kept}} kept standard-normal category thresholds.",
          "x" = "{cli::qty(kept)}{?Its/Their} Fleishman polynomial{?s} {?is/are} not increasing over the requested thresholds and the tails beyond them, so mapping the thresholds would misassign category proportions.",
          "i" = "The achieved category proportions of {cli::qty(kept)}{?this/these} variable{?s} depart from the request; less extreme category proportions or a milder {.arg skewness}/{.arg kurtosis} restore the mapping."),
        class = "efa_simulate_threshold_fallback"
      )
    }
  }

  # Draw each dataset in its own future; future.seed = TRUE assigns every
  # replicate a reproducible L'Ecuyer-CMRG stream, so the result is identical at
  # any number of workers (mirrors the np-boot fit in .boot_fun()).
  datasets <- future.apply::future_lapply(
    seq_len(n_datasets),
    function(i) {
      dat <- if (marginals == "empirical") {
        .simulate_cfm_empirical(R_pop, marginal_data, n_factors, N)
      } else if (marginals == "VM") {
        # normal deviates at the intermediate correlation, then the cubic map.
        .fleishman_transform(.simulate_cfm_mvn(vm_Rinter, N), vm_ftable)
      } else if (marginals == "IG") {
        # independent Fleishman generators mixed by the Cholesky factor: X = Z L'.
        z <- .fleishman_transform(matrix(stats::rnorm(N * p), N, p), ig_ftable)
        z %*% ig_Lt
      } else {
        .simulate_cfm_mvn(R_pop, N)
      }
      colnames(dat) <- var_names
      # Introduce missing values on the drawn (continuous) data; runs inside the future
      # so the mask draws from this replicate's stream (reproducible across worker
      # counts). When `categories` is set, the NAs propagate into the codes below.
      if (missing != "none") {
        dat <- .apply_missing(dat, missing, missing_prop, miss_b, pred_idx,
                              miss_vars)
      }
      dat
    },
    future.seed = TRUE
  )

  # Discretize to ordered categories when requested. Cutting is deterministic given
  # the continuous draw, so it runs in the main process (leaving the draw and its
  # per-replicate streams unchanged) with the same thresholds for every replicate. A
  # draw can leave a requested category empty -- more likely at small `N` or, under
  # `match = "thresholds"`, with strongly non-normal marginals -- which destabilizes
  # the polychoric correlation; report any such variable once.
  if (!is.null(ord_thresholds)) {
    k_req <- vapply(ord_thresholds, function(tau) length(tau) + 1L, integer(1L))
    datasets <- lapply(datasets, .ordinalize, thresholds = ord_thresholds)
    empty <- character(0L)
    for (d in datasets) {
      # Count non-empty categories per variable with an O(N) tabulate over the 1..K
      # codes; tabulate ignores any NA codes (from masked values), so no missing-value
      # filtering is needed.
      filled <- vapply(seq_len(ncol(d)),
                       function(j) sum(tabulate(d[, j], nbins = k_req[j]) > 0L),
                       integer(1L))
      empty <- union(empty, colnames(d)[filled < k_req])
    }
    if (length(empty)) {
      cli::cli_warn(
        c("{cli::qty(empty)}Variable{?s} {.val {empty}} did not fill every requested category.",
          "i" = "Empty or near-empty categories destabilize the polychoric correlation and the factor analysis; increase {.arg N} or request fewer categories."),
        class = "efa_simulate_empty_category"
      )
    }
  }

  data <- if (n_datasets == 1L) datasets[[1L]] else datasets
  .new_efa_simulated(
    data = data, population = R_pop, model_error = model_error_info,
    N = N, n_datasets = n_datasets, marginals = marginals,
    categories = categories, match = match, missing = missing, seed = seed)

}


# Construct the `efa_simulated` result object: the drawn `data` (an N x p matrix, a length-
# n_datasets list of them, or NULL for return_pop = TRUE), the `population` correlation matrix
# actually drawn from (model-error-perturbed when requested), the `model_error` record (NULL when
# the population is exact), and a `settings` list for provenance and printing.
.new_efa_simulated <- function(data, population, model_error, N, n_datasets,
                               marginals, categories, match, missing, seed) {
  structure(
    list(
      data = data,
      population = population,
      model_error = model_error,
      settings = list(N = N, n_datasets = n_datasets, marginals = marginals,
                      categories = categories, match = match, missing = missing,
                      seed = seed)
    ),
    class = "efa_simulated"
  )
}


# Ruscio-Kaczetow iterative rank-matching: draw `N` cases that reproduce the
# target correlation `R` while carrying the empirical column marginals of `x`
# (each column is resampled with replacement from the matching column of `x`).
# The target correlation and the marginal source are separate arguments so an
# arbitrary population `R` can be paired with any supplied marginals; the shared
# rank-matching loop otherwise follows Ruscio & Kaczetow (2008). `stats::cor()`,
# `sort()`, `sort.list()`, and `.paf_iter()` draw no random numbers, so with
# `R = stats::cor(x, method = cor_method)` the stream, and hence the output, is
# identical to the observed-data special case that `CD()` uses to draw its
# comparison populations. `x` may be a data frame or a matrix (`CD()` passes its
# raw input, `efa_simulate()` a matrix); `x[, i]` and `sample()` treat both
# identically.
# Ruscio, J., & Kaczetow, W. (2008). Simulating multivariate nonnormal data
# using an iterative algorithm. Multivariate Behavioral Research, 43(3),
# 355-381. doi: 10.1080/00273170802285693
.simulate_cfm_empirical <- function(R, x, n_factors, N, cor_method = "pearson",
                                    max_trials = 5, initial_multiplier = 1,
                                    max_iter = 100) {

  k <- ncol(x)
  # The reproduction correlates the drawn cases, which needs at least two of them; with
  # fewer there is no correlation to match and the row indexing below has nothing to read.
  # efa_simulate() gates this at its own boundary, so this catches the CD path.
  if (N < 2L) {
    cli::cli_abort(
      c("At least 2 cases must be drawn to reproduce the population correlation.",
        "x" = "You asked for {N}."),
      class = "efa_simulate_degenerate_marginal"
    )
  }
  sim_dat <- matrix(0, nrow = N, ncol = k)         # Simulated data
  dists <- matrix(0, nrow = N, ncol = k)           # Each variable's marginal
  best_RMSE <- 1                                    # Lowest RMSE correlation
  t_no_impr <- 0                                    # Trial counter

  # Resample the supplied empirical marginals (sorted) for each variable. A resample can
  # come out constant even from a non-constant source -- a category rare enough that N
  # draws miss all but one of its values -- and a constant column has no correlation, so
  # the RMSE below would be NA and the acceptance test would fail on a missing value.
  # Report it here instead (the sorted column makes the check its two extremes).
  for (i in 1:k) {
    dists[, i] <- sort(sample(x[, i], size = N, replace = TRUE))
  }
  constant <- dists[1L, ] == dists[N, ]
  if (any(constant)) {
    bad <- as.character(which(constant))
    cli::cli_abort(
      c("{cli::qty(bad)}Resampling the empirical marginals produced {?a constant column/constant columns} ({bad}).",
        "x" = "A constant column has no correlation, so the population cannot be reproduced.",
        "i" = "Draw more cases, or use a marginal whose rarest category is less rare."),
      class = "efa_simulate_degenerate_marginal"
    )
  }

  # The target correlation is supplied; keep a working and a best-so-far copy so
  # the no-improvement branch below is safe even if the first iteration does not
  # lower the initial RMSE (best_RMSE = 1).
  R_inter <- R
  R_best <- R_inter
  res_best <- matrix(0, nrow = k, ncol = k)

  # Random normal shared and unique components; factor loadings applied below.
  shared_comp <- matrix(stats::rnorm(N * n_factors, 0, 1), nrow = N,
                        ncol = n_factors)
  unique_comp <- matrix(stats::rnorm(N * k, 0, 1), nrow = N, ncol = k)
  shared_load <- matrix(0, nrow = k, ncol = n_factors)
  unique_load <- matrix(0, nrow = k, ncol = 1)

  # Iterate until a set number of trials pass without improving the RMSE between
  # the reproduced and target correlations.
  while (t_no_impr < max_trials) {

    # Factor loadings for the current intermediate correlation.
    L <- suppressWarnings(.paf_iter(rep(1, k), criterion = .001, R = R_inter,
                   n_fac = n_factors, abs_eig = TRUE, crit_type = 1,
                   max_iter = max_iter)$L)

    shared_load[, 1:n_factors] <- L

    # get rid of Heywood cases
    shared_load[shared_load > 1] <- 1
    shared_load[shared_load < -1] <- -1

    if (shared_load[1, 1] < 0) {
      shared_load <- shared_load * -1
    }

    for (i in seq_len(k)) {
      if (sum(shared_load[i, ] * shared_load[i, ]) < 1) {
        unique_load[i, 1] <-
          (1 - sum(shared_load[i, ] * shared_load[i, ]))
      } else {
        unique_load[i, 1] <- 0
      }
    }

    unique_load <- sqrt(unique_load)

    for (i in seq_len(k)) {
      sim_dat[, i] <- (shared_comp %*% t(shared_load))[, i] +
        unique_comp[, i] * unique_load[i, 1]
    }

    # Replace the normal scores with the empirical marginals via rank matching.
    for (i in seq_len(k)) {
      sim_dat <- sim_dat[sort.list(sim_dat[, i]), ]
      sim_dat[, i] <- dists[, i]
    }

    # RMSE between reproduced and target correlations; accept or shrink the step.
    R_rep <- stats::cor(sim_dat, method = cor_method)
    R_res <- R - R_rep
    RMSE <- sqrt(sum(R_res[lower.tri(R_res)] * R_res[lower.tri(R_res)]) /
                   (.5 * (k * k - k)))

    if (RMSE < best_RMSE) {
      best_RMSE <- RMSE
      R_best <- R_inter
      res_best <- R_res
      R_inter <- R_inter + initial_multiplier * R_res
      t_no_impr <- 0
    } else {
      t_no_impr <- t_no_impr + 1
      current_multiplier <- initial_multiplier * .5 ^ t_no_impr
      R_inter <- R_best + current_multiplier * res_best
    }
  }

  # Reconstruct the data set from the best intermediate correlation.
  L <- suppressWarnings(.paf_iter(rep(1, k), criterion = .001, R = R_best,
                n_fac = n_factors, abs_eig = TRUE, crit_type = 1,
                max_iter = max_iter)$L)
  shared_load[, seq_len(n_factors)] <- L

  shared_load[shared_load > 1] <- 1
  shared_load[shared_load < -1] <- -1
  if (shared_load[1, 1] < 0) {
    shared_load <- shared_load * -1
  }

  for (i in seq_len(k)) {
    if (sum(shared_load[i, ] * shared_load[i, ]) < 1) {
      unique_load[i, 1] <-
        (1 - sum(shared_load[i, ] * shared_load[i, ]))
    } else {
      unique_load[i, 1] <- 0
    }
  }

  unique_load <- sqrt(unique_load)

  for (i in seq_len(k)) {
    sim_dat[, i] <- (shared_comp %*% t(shared_load))[, i] +
      unique_comp[, i] * unique_load[i, 1]
  }

  for (i in seq_len(k)) {
    sim_dat <- sim_dat[sort.list(sim_dat[, i]), ]
    sim_dat[, i] <- dists[, i]
  }

  sim_dat
}


# Recycle a length-1 or length-p marginal-moment argument to length p (NULL gives
# zeros, the normal-marginal value), validating type and finiteness.
.moment_vec <- function(x, p, arg) {
  if (is.null(x)) return(rep(0, p))
  # A matrix passes is.numeric() and, if it has p entries, the length check below,
  # so reject any dimensioned object here rather than flatten it column-major.
  if (!is.numeric(x) || !is.null(dim(x)) || !all(is.finite(x))) {
    cli::cli_abort("{.arg {arg}} must be a finite numeric vector.",
                   class = "efa_simulate_input")
  }
  if (length(x) == 1L) return(rep(as.numeric(x), p))
  if (length(x) != p) {
    cli::cli_abort(
      c("{.arg {arg}} must have length 1 or {p} (one value per variable).",
        "x" = "It has length {length(x)}."),
      class = "efa_simulate_input"
    )
  }
  as.numeric(x)
}

# Fleishman (1978) cubic coefficients (a, b, c, d) with a = -c that transform a
# standard normal Z into a unit-variance variable with the target skewness and
# excess kurtosis via a + bZ + cZ^2 + dZ^3. The three power-method equations are
# minimised as a sum of squares (as in lavaan/semTools); a non-zero minimum means
# the target lies outside Fleishman's feasible region and is rejected. Fleishman's region
# leaves the zero-kurtosis line at a skewness of about 0.855, so `kurtosis_default` marks
# a request that only became infeasible through the unset moment defaulting to 0 -- the
# most likely cause of a rejection, and one the moments alone do not reveal.
# Fleishman, A. I. (1978). A method for simulating non-normal distributions.
# Psychometrika, 43(4), 521-532.
.fleishman_coef <- function(skewness, kurtosis, kurtosis_default = FALSE) {
  sys <- function(x) {
    b. <- x[1L]; c. <- x[2L]; d. <- x[3L]
    eq1 <- b.^2 + 6 * b. * d. + 2 * c.^2 + 15 * d.^2 - 1
    eq2 <- 2 * c. * (b.^2 + 24 * b. * d. + 105 * d.^2 + 2) - skewness
    eq3 <- 24 * (b. * d. + c.^2 * (1 + b.^2 + 28 * b. * d.) +
                 d.^2 * (12 + 48 * b. * d. + 141 * c.^2 + 225 * d.^2)) - kurtosis
    eq1^2 + eq2^2 + eq3^2
  }
  out <- stats::nlminb(start = c(1, 0, 0), objective = sys, scale = 10,
                       control = list(trace = 0L))
  if (out$objective > 1e-6) {
    cli::cli_abort(
      c("The marginal moments (skewness {.val {skewness}}, excess kurtosis {.val {kurtosis}}) are not representable by a Fleishman polynomial.",
        if (isTRUE(kurtosis_default)) {
          c("x" = "{.arg kurtosis} was not supplied and defaulted to 0, which a skewness of {.val {skewness}} needs more excess kurtosis than.")
        },
        "i" = "Fleishman's cubic covers a smaller region than excess kurtosis >= skewness^2 - 2; choose moments further inside it."),
      class = "efa_simulate_infeasible_moments"
    )
  }
  b. <- out$par[1L]; c. <- out$par[2L]; d. <- out$par[3L]
  c(-c., b., c., d.)
}

# Fleishman coefficients for every variable as a p x 4 matrix of (a, b, c, d).
.fleishman_table <- function(skewness, kurtosis, kurtosis_default = FALSE) {
  t(vapply(seq_along(skewness),
           function(i) .fleishman_coef(skewness[i], kurtosis[i], kurtosis_default),
           numeric(4L)))
}

# Apply each variable's Fleishman cubic to the columns of Z (a p x 4 coefficient
# table): X_i = a_i + b_i Z_i + c_i Z_i^2 + d_i Z_i^3.
.fleishman_transform <- function(Z, ftable) {
  for (i in seq_len(ncol(Z))) {
    z <- Z[, i]
    Z[, i] <- ftable[i, 1L] + ftable[i, 2L] * z + ftable[i, 3L] * z^2 +
      ftable[i, 4L] * z^3
  }
  Z
}

# Pairwise coefficients of the Vale-Maurelli (1983, equation 11) cubic relating the
# intermediate normal correlation rho to the attained correlation of the two Fleishman
# cubics: cor = k1 rho + k2 rho^2 + k3 rho^3. Returned as three p x p matrices, so
# .vm_intermediate_cor() (which solves the cubic for rho) and .vm_achieved_cor() (which
# evaluates it at a given rho) cannot drift apart.
# Vale, C. D., & Maurelli, V. A. (1983). Simulating multivariate nonnormal
# distributions. Psychometrika, 48(3), 465-471.
.vm_cubic_coefs <- function(ftable) {
  b <- ftable[, 2L]; cc <- ftable[, 3L]; d <- ftable[, 4L]
  list(k1 = outer(b, b) + 3 * outer(b, d) + 3 * outer(d, b) + 9 * outer(d, d),
       k2 = 2 * outer(cc, cc),
       k3 = 6 * outer(d, d))
}

# Vale-Maurelli (1983) intermediate normal correlation for each variable pair:
# solve the cubic in rho relating the intermediate normal correlation to the
# target correlation R_ij given the two variables' Fleishman coefficients. The
# cubic can admit several real roots in [-1, 1]; take the one nearest the target
# (Olvera Astivia & Zumbo, 2019). Base polyroot() is used rather than a numeric
# minimiser so all roots are found explicitly.
.vm_intermediate_cor <- function(R, ftable) {
  p <- ncol(R)
  kk <- .vm_cubic_coefs(ftable)
  ICOR <- diag(p)
  for (j in seq_len(p - 1L)) {
    for (i in (j + 1L):p) {
      target <- R[i, j]
      if (target == 0) next                # an uncorrelated pair stays at rho = 0
      # coefficients of c0 + c1 rho + c2 rho^2 + c3 rho^3
      coefs <- c(-target, kk$k1[i, j], kk$k2[i, j], kk$k3[i, j])
      # drop trailing zero coefficients so polyroot sees the true polynomial degree
      while (length(coefs) > 1L && coefs[length(coefs)] == 0) {
        coefs <- coefs[-length(coefs)]
      }
      roots <- polyroot(coefs)
      real <- Re(roots)[abs(Im(roots)) < 1e-8]
      if (!length(real)) real <- Re(roots)[which.min(abs(Im(roots)))]
      in_range <- real[real >= -1 & real <= 1]
      cand <- if (length(in_range)) in_range else real
      # A degenerate cubic (all higher coefficients zero) leaves no root; fall back
      # to the target so the assignment below never sees a zero-length value.
      if (!length(cand)) cand <- target
      rho <- cand[which.min(abs(cand - target))]
      ICOR[i, j] <- ICOR[j, i] <- rho
    }
  }
  ICOR
}

# The population correlation a Vale-Maurelli draw at intermediate correlation `Rinter`
# actually attains: the forward direction of the cubic .vm_intermediate_cor() inverts,
# evaluated at `Rinter` with the shared coefficients. Needed when the intermediate matrix
# has been projected to positive definiteness and therefore no longer solves the cubic for
# the target correlation.
.vm_achieved_cor <- function(Rinter, ftable) {
  kk <- .vm_cubic_coefs(ftable)
  R <- Rinter * kk$k1 + Rinter^2 * kk$k2 + Rinter^3 * kk$k3
  diag(R) <- 1
  (R + t(R)) / 2
}

# Independent-generator moments (Foldnes & Olsson, 2016). With X = L Z, L the
# lower Cholesky factor of the target correlation and Z independent with mean 0
# and variance 1, the additivity and homogeneity of cumulants give
# skewness(X) = (L^3) skewness(Z) and excess-kurtosis(X) = (L^4) kurtosis(Z);
# solve the lower-triangular systems for the generators' moments.
# Foldnes, N., & Olsson, U. H. (2016). A simple simulation technique for
# nonnormal data with prespecified skewness, kurtosis, and covariance matrix.
# Multivariate Behavioral Research, 51(2-3), 207-219.
.ig_generator_moments <- function(L, skewness, kurtosis) {
  list(skewness = forwardsolve(L^3, skewness),
       kurtosis = forwardsolve(L^4, kurtosis))
}


# Interior category thresholds on the standard-normal scale for each variable, from
# a `categories` specification: either a scalar or length-p count of equally probable
# categories, or a length-p list of per-variable category proportions. Cutting a
# standard normal at tau = qnorm(cumsum(prop)), dropping the trailing +Inf, yields
# categories with population proportions `prop` (Olsson, 1979). Returns a length-p
# list of numeric threshold vectors (a variable with K categories has K - 1 of them).
# Olsson, U. (1979). Maximum likelihood estimation of the polychoric correlation
# coefficient. Psychometrika, 44(4), 443-460.
.ordinal_thresholds <- function(categories, p) {
  if (is.list(categories)) {
    if (length(categories) != p) {
      cli::cli_abort(
        c("{.arg categories}, as a list of category proportions, must have one element per variable ({p}).",
          "x" = "It has {length(categories)} element{?s}."),
        class = "efa_simulate_input"
      )
    }
    lapply(seq_along(categories), function(j) {
      prop <- categories[[j]]
      # Reject a dimensioned object (matrix/array): it passes is.numeric() and would
      # be flattened column-major by cumsum() instead of read as a proportion vector
      # (mirrors the guard in .moment_vec()).
      if (!is.numeric(prop) || !is.null(dim(prop)) || length(prop) < 2L ||
          !all(is.finite(prop))) {
        cli::cli_abort(
          c("Each element of {.arg categories} must be a finite numeric vector of at least two category proportions.",
            "x" = "Element {j} is not."),
          class = "efa_simulate_input"
        )
      }
      if (any(prop <= 0)) {
        cli::cli_abort(
          c("Category proportions in {.arg categories} must be strictly positive.",
            "x" = "Element {j} has a non-positive proportion (a zero proportion is an empty category with no threshold)."),
          class = "efa_simulate_input"
        )
      }
      if (abs(sum(prop) - 1) > 1e-6) {
        cli::cli_abort(
          c("Category proportions in {.arg categories} must sum to 1.",
            "x" = "Element {j} sums to {.val {sum(prop)}}."),
          class = "efa_simulate_input"
        )
      }
      # Renormalize onto the exact simplex: floating-point slack in `prop` (within the
      # tolerance above) could otherwise push an interior cumulative proportion past 1,
      # which qnorm would turn into a NaN threshold. The outer gate has already rejected
      # any genuine mismatch. An interior cumulative of exactly 1 survives this and gives
      # a +Inf threshold, which is legal -- findInterval() handles it and the category
      # above is simply always empty, which the empty-category warning reports.
      prop <- prop / sum(prop)
      stats::qnorm(cumsum(prop)[-length(prop)])
    })
  } else {
    # Reject a dimensioned object, as for the proportion-vector branch above.
    if (!is.numeric(categories) || !is.null(dim(categories)) ||
        !all(is.finite(categories)) || any(categories != round(categories))) {
      cli::cli_abort(
        "{.arg categories} must be a whole number of categories per variable, or a list of category proportions.",
        class = "efa_simulate_input"
      )
    }
    if (length(categories) != 1L && length(categories) != p) {
      cli::cli_abort(
        c("{.arg categories}, as a count of categories, must have length 1 or {p} (one per variable).",
          "x" = "It has length {length(categories)}."),
        class = "efa_simulate_input"
      )
    }
    if (any(categories < 2L)) {
      cli::cli_abort(
        c("{.arg categories} must request at least two categories per variable.",
          "x" = "The smallest requested count is {.val {min(categories)}}."),
        class = "efa_simulate_input"
      )
    }
    counts <- if (length(categories) == 1L) rep(categories, p) else categories
    lapply(counts, function(k) stats::qnorm(seq_len(k - 1L) / k))
  }
}


# Exact probability mass a Fleishman cubic f assigns at or below the cut f(t0): decompose
# the real line at the real roots of f(z) = f(t0) and add the standard-normal mass of every
# interval on which f - f(t0) <= 0 (sign tested at an interior point). Used to detect tail
# fold-back: for a non-monotone cubic, P(f(Z) <= f(t0)) can differ from pnorm(t0) even when
# f increases across the thresholds themselves, because a tail segment beyond t0 dips back
# below the cut. Returns NA for a degenerate (constant) map.
.vm_cut_mass <- function(t0, b, cc, d) {
  # f(z) - f(t0) in increasing degree; the constant Fleishman term cancels
  co <- c(-(b * t0 + cc * t0^2 + d * t0^3), b, cc, d)
  while (length(co) > 1L && co[length(co)] == 0) co <- co[-length(co)]
  if (length(co) <= 1L) return(NA_real_)
  r <- polyroot(co)
  re <- sort(unique(Re(r[abs(Im(r)) < 1e-8])))
  cuts <- c(-Inf, re, Inf)
  mass <- 0
  for (i in seq_len(length(cuts) - 1L)) {
    lo <- cuts[i]; hi <- cuts[i + 1L]
    mid <- if (is.infinite(lo) && is.infinite(hi)) 0
    else if (is.infinite(lo)) hi - 1
    else if (is.infinite(hi)) lo + 1
    else (lo + hi) / 2
    if (b * mid + cc * mid^2 + d * mid^3 -
        (b * t0 + cc * t0^2 + d * t0^3) <= 0) {
      mass <- mass + (stats::pnorm(hi) - stats::pnorm(lo))
    }
  }
  mass
}

# Map standard-normal ordinal thresholds onto the Vale-Maurelli scale. The VM draw is
# X = f(Z) with f the per-variable Fleishman cubic a + bZ + cZ^2 + dZ^3, so where f is
# order-preserving over (essentially) all the normal mass, P(X <= f(tau)) = P(Z <= tau) and
# cutting X at f(tau) reproduces exactly the proportions tau was built for. Two screens
# check this. First, f must increase across the requested thresholds themselves, or the cut
# points come out of order: the derivative b + 2cz + 3dz^2 is a parabola, so its minimum
# over the closed threshold range is attained at an endpoint or, when the parabola opens
# upward (d > 0), at the vertex -c/(3d) if that falls inside. Second, the tails beyond the
# outermost thresholds must not fold back below the cuts: a non-monotone cubic (every
# platykurtic marginal has d < 0; a skewed one can dip on one side) re-crosses an outer cut
# value at some more extreme z, and the mass beyond that re-crossing is silently assigned to
# the wrong side -- 75% of a 1.4% top category at excess kurtosis -1, 84% of a 5% bottom
# category at skewness 1.5 / excess kurtosis 3. The exact misassigned mass at the two
# outermost thresholds is compared against the requested outer-category proportions
# (checking the outermost cuts suffices: an interior cut's re-crossing lies further out and
# folds strictly less mass) and a variable fails when it is off by more than 5% of the
# proportion -- departures below that are negligible against the error the normal-scale
# fallback itself incurs. The indices of the failing variables are returned; those keep
# their normal-scale thresholds. Infinite thresholds are passed through: an increasing map
# sends +-Inf to itself, and evaluating the polynomial there could produce a NaN cut point.
# Fleishman, A. I. (1978). A method for simulating non-normal distributions.
# Psychometrika, 43(4), 521-532.
.vm_thresholds <- function(thresholds, ftable) {
  non_monotone <- integer(0L)
  for (j in seq_along(thresholds)) {
    tau <- thresholds[[j]]
    fin <- is.finite(tau)
    if (!any(fin)) next                 # nothing to map
    b <- ftable[j, 2L]; cc <- ftable[j, 3L]; d <- ftable[j, 4L]
    rng <- range(tau[fin])
    z <- rng
    if (d > 0) {
      zv <- -cc / (3 * d)
      if (zv > z[1L] && zv < z[2L]) z <- c(z, zv)
    }
    if (any(b + 2 * cc * z + 3 * d * z^2 <= 0)) {
      non_monotone <- c(non_monotone, j)
      next
    }
    p_lo <- stats::pnorm(rng[1L])
    p_hi <- 1 - stats::pnorm(rng[2L])
    m_lo <- .vm_cut_mass(rng[1L], b, cc, d)
    m_hi <- .vm_cut_mass(rng[2L], b, cc, d)
    if (is.na(m_lo) || is.na(m_hi) ||
        abs(m_lo - p_lo) > 0.05 * p_lo ||
        abs((1 - m_hi) - p_hi) > 0.05 * p_hi) {
      non_monotone <- c(non_monotone, j)
      next
    }
    tau[fin] <- .fleishman_transform(matrix(tau[fin], ncol = 1L),
                                     ftable[j, , drop = FALSE])[, 1L]
    thresholds[[j]] <- tau
  }
  list(thresholds = thresholds, non_monotone = non_monotone)
}


# Discretize the columns of a continuous draw into ordered category codes 1..K,
# cutting each column at its interior thresholds (a length-p list). findInterval
# returns the count of thresholds at or below each value (0..K-1); +1 shifts to
# 1-based codes.
.ordinalize <- function(dat, thresholds) {
  out <- matrix(NA_integer_, nrow(dat), ncol(dat), dimnames = dimnames(dat))
  for (j in seq_len(ncol(dat))) {
    out[, j] <- findInterval(dat[, j], thresholds[[j]]) + 1L
  }
  out
}


# Resolve a column specification (variable names, or column indices) to a vector of
# column indices in 1..p, shared by `missing_vars` and `missing_predictor` so both report
# the same failures under their own argument name. The range is tested on the supplied
# values rather than on as.integer() of them: coercing first turns an index beyond
# .Machine$integer.max into NA, which would leave the range test an `if (NA)` and abort
# with an unclassed base error. A dimensioned or fractional spec is rejected rather than
# flattened column-major or rounded silently (mirrors the guard in .moment_vec()).
.resolve_column_spec <- function(spec, p, var_names, arg) {
  if (is.character(spec)) {
    idx <- match(spec, var_names)
    if (anyNA(idx)) {
      bad <- spec[is.na(idx)]
      cli::cli_abort(
        c("{.arg {arg}} names a variable not in the data.",
          "x" = "{cli::qty(bad)}Unknown name{?s}: {.val {bad}}."),
        class = "efa_simulate_input"
      )
    }
    return(idx)
  }
  if (!is.numeric(spec) || !is.null(dim(spec)) || !all(is.finite(spec)) ||
      any(spec != round(spec))) {
    cli::cli_abort(
      "{.arg {arg}} must be whole-number column indices or variable names.",
      class = "efa_simulate_input"
    )
  }
  out_of_range <- spec < 1 | spec > p
  if (any(out_of_range)) {
    # Formatted to character before interpolation, and given an explicit `cli::qty()`:
    # a numeric vector supplies no usable quantity for the plural marker, so leaving it
    # numeric aborts inside cli once more than one value is out of range.
    bad <- as.character(unique(spec[out_of_range]))
    cli::cli_abort(
      c("{.arg {arg}} indexes a column outside 1 to {p}.",
        "x" = "{cli::qty(bad)}It has value{?s} {.val {bad}}."),
      class = "efa_simulate_input"
    )
  }
  as.integer(spec)
}


# Resolve the `missing_vars` specification to the vector of column indices to hole.
# NULL means every column (the default: the mechanism applies to the whole data set). A
# supplied spec must reference existing, distinct columns; the order is kept, as
# `missing_predictor` is aligned to it element by element. Restricting the holed columns
# is what makes a strictly ignorable MAR design possible: a predictor outside
# `missing_vars` is fully observed, so the missingness depends only on data the analyst
# sees.
.resolve_missing_vars <- function(spec, p, var_names) {
  if (is.null(spec)) return(seq_len(p))
  idx <- .resolve_column_spec(spec, p, var_names, "missing_vars")
  if (length(idx) == 0L || anyDuplicated(idx) > 0L) {
    cli::cli_abort(
      c("{.arg missing_vars} must name at least one variable, each at most once.",
        "i" = "Leave it {.code NULL} to hole every variable."),
      class = "efa_simulate_input"
    )
  }
  idx
}


# Resolve the MAR predictor specification to one column index per holed variable (the
# columns in `vars`, in that order): which other variable's value drives that variable's
# missingness. NULL defaults to the next variable cyclically (variable p is driven by
# variable 1), so every variable has a distinct predictor. A supplied spec (one index or
# name per holed variable) must reference existing columns other than the variable itself
# -- a variable predicting its own missingness would be MNAR, not MAR (which also rules
# out a single recycled predictor, as it would predict itself). The cyclic default is
# order-dependent, so an explicit spec is preferable when the column order is arbitrary;
# it is also the way to keep the predictors outside `vars`, which is what makes the
# mechanism ignorably MAR.
.resolve_missing_predictor <- function(spec, p, var_names, vars = seq_len(p)) {
  if (p < 2L) {
    cli::cli_abort(
      c("{.code missing = \"MAR\"} needs at least two variables.",
        "x" = "With a single variable there is no other predictor for the missingness.",
        "i" = "Use {.code missing = \"MNAR\"} or {.val MCAR} for a single variable."),
      class = "efa_simulate_input"
    )
  }
  # Cyclic next variable: 2, 3, ..., p, 1 (never a variable's own position), taken for
  # the holed columns only.
  if (is.null(spec)) return(c(seq_len(p)[-1L], 1L)[vars])

  idx <- .resolve_column_spec(spec, p, var_names, "missing_predictor")

  if (length(idx) != length(vars)) {
    cli::cli_abort(
      c("{.arg missing_predictor} must give one predictor per variable it applies to ({length(vars)}).",
        "x" = "It has length {length(idx)}.",
        "i" = "A single predictor for every variable is not allowed, as that variable would predict its own missingness (MNAR)."),
      class = "efa_simulate_input"
    )
  }
  if (any(idx == vars)) {
    self <- as.character(vars[idx == vars])
    cli::cli_abort(
      c("{.arg missing_predictor} must reference another variable, not the variable itself.",
        "x" = "{cli::qty(self)}Variable{?s} {self} {?is/are} set to predict {?its/their} own missingness, which is MNAR rather than MAR.",
        "i" = "Use {.code missing = \"MNAR\"} for self-dependent missingness."),
      class = "efa_simulate_input"
    )
  }
  idx
}


# Solve for the logistic intercept a so the mean missing probability
# mean(plogis(a + b * z)) equals the target `prop`. The mean is strictly increasing in a
# from 0 to 1, so a unique root exists; the bracket qlogis(prop) +- (|b| max|z| + 10)
# forces a + b*z <= qlogis(prop) - 10 at the low end (mean below prop) and
# >= qlogis(prop) + 10 at the high end (mean above prop), so the ends are opposite-signed
# for any prop in (0, 1) and any finite b. uniroot draws no random numbers.
.calibrate_missing_intercept <- function(z, b, prop) {
  f <- function(a) mean(stats::plogis(a + b * z)) - prop
  # max|z| lives at an extremum, so range() avoids materializing a length-n abs(z).
  reach <- abs(b) * max(abs(range(z))) + 10
  centre <- stats::qlogis(prop)
  stats::uniroot(f, interval = c(centre - reach, centre + reach),
                 tol = .Machine$double.eps^0.5)$root
}


# Introduce missing values into a drawn data matrix under a chosen mechanism (Rubin,
# 1976). Each variable in `vars` (every column by default) is holed at (in expectation)
# `prop` of its cases; columns outside `vars` stay complete. MCAR draws a Bernoulli(prop)
# mask independent of the data. MAR and MNAR set each case's missing probability by a
# logistic model, logit(p_i) = a + b * z_i, where z standardizes a predictor: another
# variable (MAR, given by `pred_idx`, one entry per element of `vars`) or the variable's
# own value (MNAR). The intercept a is calibrated per variable so the mean missing
# probability is `prop`; the slope b (`strength`) sets the dependence. Predictors are read
# from a single pre-loop copy of the complete draw so an earlier column's holes cannot
# corrupt a later column's predictor (R copy-on-modify leaves `complete` untouched once
# `dat` is written). A constant predictor (sd 0) contributes z = 0, degrading that
# variable to MCAR. Exactly one runif(n) is drawn per holed column in fixed order, never
# short-circuited, so the RNG stream -- and hence the reproducibility across worker
# counts -- does not depend on the mechanism or the achieved probabilities.
# Rubin, D. B. (1976). Inference and missing data. Biometrika, 63(3), 581-592.
.apply_missing <- function(dat, mechanism, prop, strength, pred_idx,
                           vars = seq_len(ncol(dat))) {
  n <- nrow(dat)
  complete <- dat
  for (i in seq_along(vars)) {
    j <- vars[i]
    prob <- if (mechanism == "MCAR") {
      prop                                 # recycled against runif(n) below
    } else {
      xk <- complete[, if (mechanism == "MAR") pred_idx[i] else j]
      s <- stats::sd(xk)
      z <- if (s > 0) (xk - mean(xk)) / s else rep(0, n)
      a <- .calibrate_missing_intercept(z, strength, prop)
      stats::plogis(a + strength * z)
    }
    dat[stats::runif(n) < prob, j] <- NA
  }
  dat
}


# Perturb a population correlation matrix so a `q`-factor model fits it with a prescribed
# discrepancy, injecting "model error" (all models are approximations, so an exact-fit
# population overstates recovery; MacCallum, 2003). Recovers the model's unrotated orthogonal
# loadings from the common part (`common` = Lambda Phi Lambda', `Sigma_cov` the assembled
# covariance) -- the standardized common matrix `C_std` has the communalities on its diagonal,
# and its top-`q` eigenpairs give loadings `L` with `L L' + diag(psi) == R_pop` -- then hands
# them to the chosen method. Returns the perturbed correlation and the achieved fit -- the misfit
# of the specified q-factor (generating) model to the perturbed population, measured with the
# shared population-limit wrapper (.efa_population_fit) using EFA()'s fit-index formulas. For CB
# the generating model is the minimizer, so this equals the target; for TKL/WB it is the
# generating model's misfit (as in fungible/noisemaker), which a re-fitted EFA of the data may
# recover as somewhat smaller since it re-optimizes the loadings.
.apply_model_error <- function(R_pop, common, Sigma_cov, q, method,
                               target_rmsea, target_cfi, force_pd, tol = 1e-8) {
  p <- nrow(R_pop)
  h2 <- diag(common) / diag(Sigma_cov)     # standardized communalities
  u <- 1 - h2                              # uniquenesses
  C_std <- R_pop
  diag(C_std) <- h2
  ev <- eigen(C_std, symmetric = TRUE)
  L <- ev$vectors[, seq_len(q), drop = FALSE] %*%
    diag(sqrt(pmax(ev$values[seq_len(q)], 0)), q)
  psi <- 1 - rowSums(L^2)

  # The recovered L reproduces R_pop only when the population is exactly a rank-q common factor
  # structure with diagonal, non-negative uniquenesses. A correlated-residual (off-diagonal) Psi
  # or a communality above 1 breaks that: the eigen-recovered model would misrepresent the
  # population, so CB would centre on the wrong model (missing its exact-target guarantee), TKL
  # would take the square root of a negative uniqueness, and the reported achieved fit would be
  # measured against the wrong model. Reject such populations rather than silently mislead.
  if (any(psi < -tol) ||
      max(abs(tcrossprod(L) + diag(psi, p) - R_pop)) > 1e-8) {
    cli::cli_abort(
      c("Model error requires a population that is exactly a {q}-factor structure with non-negative uniquenesses.",
        "x" = "The population has correlated residuals (an off-diagonal {.arg Psi}) or a communality above 1, which the {q}-factor model cannot represent.",
        "i" = "Use a factor model with a diagonal {.arg Psi} and communalities below 1, or omit the model-error target."),
      class = "efa_simulate_model_error"
    )
  }

  me <- switch(
    method,
    CB  = .model_error_cb(R_pop, L, q, target_rmsea, force_pd),
    TKL = .model_error_tkl(R_pop, u, q, target_rmsea, target_cfi),
    WB  = .model_error_wb(R_pop, target_rmsea)
  )

  # Achieved fit of the major q-factor model on the perturbed population (see the header
  # above for what each method's value means).
  fit <- .efa_population_fit(L, me$population)
  model_error <- c(
    list(method = method, target_rmsea = target_rmsea, target_cfi = target_cfi,
         rmsea = fit$rmsea, cfi = fit$cfi, df = fit$df),
    me$params
  )
  list(population = me$population, model_error = model_error)
}


# Cudeck & Browne (1992) model error: construct Sigma* = Omega + kappa E such that the q-factor
# model (unrotated loadings `L`; its uniquenesses follow from `R_pop`) remains the minimizer of the
# ML discrepancy and the minimum equals delta = df * target_rmsea^2, so the achieved RMSEA equals
# the target.
# Follows MBESS::Sigma.2.SigmaStar at the correlation scale: differentiate the model-implied
# Sigma(theta) = Lt Lt' + diag(psi) with respect to theta = (vec(L), psi), form the ML-metric
# Jacobian columns B[,i] = -d * vech(W^-1 dSigma_i W^-1) (W = Omega, d the vech weights
# that make an ordinary residual orthogonal in the ML metric), and take E from the part of a
# random symmetric matrix orthogonal to B. G = W^-1 E has the same eigenvalues s as the symmetric
# W^{-1/2} E W^{-1/2}, so the discrepancy is kappa*sum(s) - sum(log(1 + kappa*s)); it is monotone
# in kappa up to the positive-definite boundary 1 + kappa*min(s) > 0. kappa is the exact root
# (uniroot on the monotone equation, unlike MBESS's nlm on |.|, which is less precise near the
# kink and would miss the target here). A direction that cannot reach delta while staying positive
# definite is redrawn; if none succeeds, force_pd = TRUE returns the closest positive-definite
# approximation (a smaller RMSEA, with a warning), otherwise the call aborts.
# Cudeck, R., & Browne, M. W. (1992). Constructing a covariance matrix that yields a specified
# minimizer and a specified minimum discrepancy function value. Psychometrika, 57(3), 357-369.
.model_error_cb <- function(R_pop, L, q, target_rmsea, force_pd,
                            max_tries = 50L) {
  p <- nrow(R_pop)
  df <- .efa_df(p, q)
  delta <- target_rmsea^2 * df
  pstar <- p * (p + 1) / 2

  W <- R_pop
  Wi <- solve(W)
  # vech inner-product weights (1 on the diagonal, 2 off it): with these, an ordinary residual
  # against B is orthogonal in the ML metric, which is what keeps theta the minimizer.
  wmat <- matrix(2, p, p)
  diag(wmat) <- 1
  dvec <- .vech(wmat)

  # The Jacobian columns in closed form. Elementwise Sigma_ij(theta) = sum_k L_ik L_jk +
  # [i == j] psi_i, so
  #   dSigma / dL[a, b] = e_a t(L[, b]) + L[, b] t(e_a)     -> column (b - 1) * p + a
  #   dSigma / dpsi[a]  = e_a t(e_a)                        -> column p * q + a,
  # ordered to match theta = (vec(L), psi), which R fills column-major. Each ML sandwich is then
  # a sum of outer products,
  #   Wi (e_a t(y) + y t(e_a)) Wi = Wi[, a] t(t(Wi) y) + (Wi y) t(Wi[a, ]),
  # whose vech picks x[ii] * y[jj] over the lower-triangle index pairs, so no p x p product is
  # formed per column. Wi[, a] and Wi[a, ] are kept apart because solve() need not return an
  # exactly symmetric inverse.
  #
  # Differentiating in closed form rather than by a finite difference is what keeps the draw
  # below reproducible: differencing divides a cancelling subtraction by the step size, which
  # lifts the q(q - 1)/2 rotational null directions of the model off zero to roughly eps/step.
  # Their orientation is then set purely by rounding, qr() counts them as real columns, and
  # qr.resid() projects them out -- so E, and with it the perturbed population, would swing by
  # tens of percent between BLAS implementations for one and the same seed.
  idx <- which(!upper.tri(matrix(0, p, p)), arr.ind = TRUE)   # the vech pairs, in .vech order
  ii <- idx[, 1L]
  jj <- idx[, 2L]
  WL <- Wi %*% L            # columns: Wi %*% L[, b]
  WtL <- crossprod(Wi, L)   # columns: t(Wi) %*% L[, b]
  npar <- p * q + p
  B <- matrix(0, pstar, npar)
  for (a in seq_len(p)) {
    wi_i <- Wi[ii, a]       # (Wi e_a)[ii]
    wi_j <- Wi[a, jj]       # (t(e_a) Wi)[jj]
    for (b in seq_len(q)) {
      B[, (b - 1L) * p + a] <- -dvec * (wi_i * WtL[jj, b] + WL[ii, b] * wi_j)
    }
    B[, p * q + a] <- -dvec * (wi_i * wi_j)
  }
  Bqr <- qr(B)

  # symmetric whitening W^{-1/2} for the eigenvalues s that drive both the discrepancy and the
  # positive-definite bound on kappa.
  Weig <- eigen(W, symmetric = TRUE)
  Wih <- Weig$vectors %*% diag(1 / sqrt(Weig$values), p) %*% t(Weig$vectors)

  best_pop <- NULL       # closest positive-definite approximation, for the force_pd escape
  best_gap <- -Inf       # its (negative) shortfall in discrepancy relative to delta
  for (tr in seq_len(max_tries)) {
    e <- qr.resid(Bqr, stats::rnorm(pstar))
    E <- matrix(0, p, p)
    E[lower.tri(E, diag = TRUE)] <- e
    E <- E + t(E) - diag(diag(E))
    s <- eigen(Wih %*% E %*% Wih, symmetric = TRUE, only.values = TRUE)$values
    trG <- sum(s)
    fdisc <- function(k) k * trG - sum(log1p(k * s)) - delta
    hi <- if (min(s) < 0) -1 / min(s) * (1 - 1e-9) else 1e6
    gap <- fdisc(hi)                          # discrepancy reachable at the PD boundary, minus delta
    if (gap >= 0) {
      kappa <- stats::uniroot(fdisc, c(1e-12, hi),
                              tol = .Machine$double.eps^0.5)$root
      return(list(population = stats::cov2cor(R_pop + kappa * E),
                  params = list(kappa = kappa)))
    }
    if (gap > best_gap) {                      # track the direction that comes closest to delta
      best_gap <- gap
      best_pop <- stats::cov2cor(R_pop + hi * E)
    }
  }

  # No direction reached delta while staying positive definite: the target is too large for this
  # population. Either accept the closest positive-definite approximation or abort.
  if (isTRUE(force_pd) && !is.null(best_pop)) {
    cli::cli_warn(
      c("The requested {.arg target_rmsea} could not be reached while keeping the population positive definite; the closest positive-definite approximation was used.",
        "i" = "The achieved RMSEA is below the target; lower {.arg target_rmsea} or use {.code model_error = \"TKL\"} for a larger misfit."),
      class = "efa_simulate_pd_forced"
    )
    return(list(population = best_pop, params = list(kappa = NA_real_)))
  }
  cli::cli_abort(
    c("The Cudeck-Browne model error cannot reach {.arg target_rmsea} = {target_rmsea} while keeping the population positive definite.",
      "i" = "Lower {.arg target_rmsea}, use {.code model_error = \"TKL\"} or {.val WB}, or set {.code force_pd = TRUE} to accept the closest positive-definite approximation."),
    class = "efa_simulate_model_error"
  )
}


# Tucker, Koopman & Linn (1969) model error: add a set of `n_minor` minor common factors to the
# population so the q-factor major model misfits it by a target RMSEA (and, optionally, CFI). The
# minor loadings `W0` are standard normal; a geometric column decay (1 - eps)^(0:(n_minor-1))
# concentrates variance in the first minor factors, and each row is rescaled so the minor common
# variance of variable i equals v * u_i (v is the proportion of its uniqueness u_i reallocated to
# minor factors). The perturbed population is R_pop + W W' (diagonal reset to 1). The two knobs
# (v, eps) are chosen by bounded optimization (L-BFGS-B with random restarts) to minimise the
# weighted relative squared deviation from the target(s); with a single target the fit is near
# exact, with both targets it is a compromise. Ft (major-model discrepancy) and Fb (independence
# baseline) use the same ML discrepancy as .gof()/.efa_population_fit().
# Tucker, L. R., Koopman, R. F., & Linn, R. L. (1969). Evaluation of factor analytic research
# procedures by means of simulated correlation matrices. Psychometrika, 34(4), 421-459.
.model_error_tkl <- function(R_pop, u, q, target_rmsea, target_cfi,
                             n_minor = 50L, max_tries = 25L) {
  p <- nrow(R_pop)
  df <- .efa_df(p, q)
  W0 <- matrix(stats::rnorm(p * n_minor), p, n_minor)
  Rinv <- solve(R_pop)
  ldRpop <- as.numeric(determinant(R_pop, logarithm = TRUE)$modulus)
  has_r <- !is.null(target_rmsea)
  has_c <- !is.null(target_cfi)

  obj <- function(par, return_vals = FALSE) {
    v <- par[1L]
    eps <- par[2L]
    # Column j of the minor loadings decays by (1 - eps)^(j-1); scale columns directly rather
    # than multiplying by a dense n_minor x n_minor diagonal on every objective evaluation.
    Wc <- sweep(W0, 2L, (1 - eps)^(0:(n_minor - 1L)), "*")
    wsq <- rowSums(Wc^2)
    Wc <- sqrt(v * u / wsq) * Wc          # scale row i so rowSums(Wc^2) = v * u_i
    RpopME <- R_pop + tcrossprod(Wc)
    diag(RpopME) <- 1
    ldME <- as.numeric(determinant(RpopME, logarithm = TRUE)$modulus)
    Ft <- ldRpop - ldME + sum(Rinv * RpopME) - p     # ML discrepancy of the major model
    Fb <- -ldME                                      # independence baseline
    rmsea <- sqrt(max(0, Ft) / df)
    cfi <- 1 - Ft / Fb
    if (return_vals) {
      return(list(population = RpopME, v = v, eps = eps, rmsea = rmsea, cfi = cfi))
    }
    val <- 0
    if (has_r) val <- val + (rmsea - target_rmsea)^2 / target_rmsea^2
    if (has_c) val <- val + ((1 - cfi) - (1 - target_cfi))^2 / (1 - target_cfi)^2
    if (!is.finite(val)) 1e6 else val
  }

  best_par <- NULL
  best_val <- Inf
  for (tr in seq_len(max_tries)) {
    start <- c(stats::runif(1, 0.02, 0.9), stats::runif(1, 0, 0.8))
    opt <- tryCatch(
      stats::optim(start, obj, method = "L-BFGS-B",
                   lower = c(0.001, 0), upper = c(1, 1)),
      error = function(e) NULL)
    if (is.null(opt)) next
    if (opt$value < best_val) {
      best_val <- opt$value
      best_par <- opt$par
    }
    if (opt$convergence == 0 && opt$value < 1e-8) break   # target(s) essentially hit
  }
  if (is.null(best_par)) {
    cli::cli_abort(
      c("The Tucker-Koopman-Linn optimization failed to find minor-factor weights for the requested target(s).",
        "i" = "Try a different {.arg target_rmsea}/{.arg target_cfi}, or {.code model_error = \"CB\"}."),
      class = "efa_simulate_model_error")
  }
  res <- obj(best_par, return_vals = TRUE)
  # `best_val` is the sum of the squared relative deviations from the requested target(s),
  # so its square root is the relative miss. Two targets over two knobs need not both be
  # reachable -- a high target CFI dominates the weighted objective and can halve the
  # achieved RMSEA -- and the compromise is otherwise only visible by reading the achieved
  # values back off the result. Flag a miss of more than 20% of the target: an ordinary
  # two-target compromise (say .05/.95, which lands within a tenth of both) is close
  # enough to be the intended answer, while a jointly infeasible pair misses by 50% or
  # more, so the threshold sits between the two rather than at the edge of the former.
  if (sqrt(best_val) > 0.2) {
    # Name the target(s) actually requested, so a CFI-only run is not told about an RMSEA
    # target it never set; both achieved values are reported either way.
    requested <- c(if (has_r) "RMSEA" else NULL, if (has_c) "CFI" else NULL)
    targets <- c(if (has_r) target_rmsea else NULL, if (has_c) target_cfi else NULL)
    cli::cli_warn(
      c("The Tucker-Koopman-Linn minor factors could not reach the requested {requested} target{?s} of {.val {targets}}.",
        "x" = "The closest compromise achieves an RMSEA of {.val {round(res$rmsea, 4)}} and a CFI of {.val {round(res$cfi, 4)}}.",
        "i" = "The targets are not jointly attainable by the two minor-factor knobs for this population; relax one of them, supply a single target, or use {.code model_error = \"CB\"} to match the RMSEA exactly."),
      class = "efa_simulate_model_error_compromise")
  }
  list(population = res$population, params = list(v = res$v, eps = res$eps))
}


# Wu & Browne (2015) model error: draw the population from an inverse-Wishart distribution
# centered on the model-implied correlation Omega, Sigma ~ IW(m, m Omega), where the precision
# m = 1 / target_rmsea^2 sets the expected RMSEA to approximately the target (via the Wu-Browne
# small-error relation v ~= epsilon^2; the realized RMSEA is biased slightly upward, more so for
# larger targets). The drawn covariance is
# standardized to a correlation matrix; the achieved RMSEA is random around the target (this is a
# single random draw, not an exact construction), so the realized value is reported. Requires
# m >= p for a proper draw (a very large target RMSEA is rejected).
# Wu, H., & Browne, M. W. (2015). Quantifying adventitious error in a covariance structure as a
# random effect. Psychometrika, 80(3), 571-600.
.model_error_wb <- function(R_pop, target_rmsea) {
  p <- nrow(R_pop)
  m <- 1 / target_rmsea^2
  if (m < p) {
    cli::cli_abort(
      c("{.arg target_rmsea} = {target_rmsea} is too large for the Wu-Browne method with {p} variable{?s}.",
        "i" = "The precision m = 1 / target_rmsea^2 = {round(m, 1)} must be at least the number of variables ({p}); use a smaller {.arg target_rmsea}."),
      class = "efa_simulate_model_error")
  }
  Sigma <- .riwish(m, m * R_pop)
  list(population = stats::cov2cor(Sigma), params = list(m = m))
}


# One inverse-Wishart draw Sigma ~ IW(nu, S) via the Bartlett decomposition (no external
# dependency): S^-1 = C C', and A A' ~ Wishart(nu, S^-1) with A = C T, T lower-triangular whose
# diagonal is sqrt(chi-square(nu - i + 1)) and whose strictly-lower entries are standard normal;
# Sigma = (A A')^-1. Draws its randomness (rchisq, rnorm) from the caller's stream, so it is
# reproducible under a fixed seed.
.riwish <- function(nu, S) {
  p <- nrow(S)
  C <- t(chol(solve(S)))
  A <- matrix(0, p, p)
  diag(A) <- sqrt(stats::rchisq(p, df = nu - seq_len(p) + 1))
  A[lower.tri(A)] <- stats::rnorm(p * (p - 1) / 2)
  CA <- C %*% A
  Sig <- solve(tcrossprod(CA))
  (Sig + t(Sig)) / 2
}

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.