Nothing
# cor_method = "poly" / "tetra": polychoric and tetrachoric correlations wired into
# the public API. The matrix estimator itself is covered in test-polychoric.R; these
# tests cover the routing through .prepare_cor_input() (EFA and the suitability /
# retention functions), the `use` handling, the EFA() bootstrap recompute, the
# tetrachoric binary assertion, and the clean rejection by the criteria whose
# reference data are continuous (CD, PARALLEL, NEST, HULL).
# The bootstrap reproducibility assertions below compare two separate invocations, so they
# use expect_equal() with an explicit tolerance rather than expect_identical(): a threaded
# BLAS (Apple's Accelerate, for one) is free to vary its GEMM reduction order between calls,
# so one function on one input can differ in the last ulp. waldo still compares S3 classes,
# names, attributes and structure exactly, so only numeric values are given slack -- a seed
# that was ignored resamples different cases entirely and still fails loudly. (A set tolerance
# does relax integer against double, which these floating-point SEs never are.)
fp_tol <- 1e-8
# Muffle only the named warning classes, letting any other (unexpected) warning
# surface so it is not silently hidden by a blanket suppressWarnings().
.muffle <- function(expr, ...) {
classes <- c(...)
withCallingHandlers(expr, warning = function(w) {
if (inherits(w, classes)) invokeRestart("muffleWarning")
})
}
# `.warn_classes()` -- which collects the class strings of every warning raised while
# evaluating an expression, so a single expected class can be asserted without
# expect_warning stopping at the first one -- lives in helper-efa_retention.R.
# A six-item ordinal set whose first pair is at a Frechet bound (its response table is the
# comonotone coupling of its own margins), so that pair is estimated at the boundary of the
# parameter space and has no asymptotic variance; the other four items are ordinary filler.
# Deterministic: a fixed contingency table expanded to raw data, plus seeded filler.
.degenerate_ordinal <- function() {
tab <- rbind(c(41, 0), c(13, 0), c(28, 0), c(171, 47))
pair <- do.call(rbind, lapply(which(tab > 0), function(k) {
a <- ((k - 1L) %% nrow(tab)) + 1L
b <- ((k - 1L) %/% nrow(tab)) + 1L
cbind(rep(a, tab[a, b]), rep(b, tab[a, b]))
}))
set.seed(11)
filler <- matrix(sample(1:4, nrow(pair) * 4L, TRUE), nrow(pair), 4L)
out <- cbind(pair, filler)
colnames(out) <- paste0("i", seq_len(ncol(out)))
out
}
test_that("EFA() runs ordinal factor analysis and matches psych::fa on a polychoric matrix", {
skip_on_cran()
skip_if_not_installed("psych")
x <- DOSPERT_raw[stats::complete.cases(DOSPERT_raw), ]
N <- nrow(x)
k <- 3L
efa <- EFA(x, n_factors = k, method = "ULS", cor_method = "poly",
rotation = "none")
# Reference: psych's unweighted least squares factor analysis of psych's own
# polychoric matrix, with correct = FALSE so psych applies no empty-cell continuity
# correction, matching .polychoric(). fm = "uls" minimises the same criterion as this
# package's ULS.
Rp <- suppressWarnings(psych::polychoric(x, correct = FALSE, global = FALSE)$rho)
fa_ref <- suppressWarnings(psych::fa(Rp, nfactors = k, fm = "uls",
rotate = "none", n.obs = N))
L_ref <- unclass(fa_ref$loadings)
L <- efa$unrot_loadings
# Compare via the reproduced common-factor correlations (L %*% t(L)), which are
# invariant to factor sign and column order, so no fragile per-column alignment
# is needed. The tolerance is dominated by the two polychoric matrices, which agree
# to ~1e-4, not by the extraction, so it stays at 1e-3.
expect_lt(max(abs(tcrossprod(L) - tcrossprod(L_ref))), 1e-3)
expect_lt(max(abs(efa$h2 - fa_ref$communality)), 1e-3)
})
test_that("cor_method = 'tetra' equals 'poly' on binary data", {
g <- GRiPS_raw[stats::complete.cases(GRiPS_raw), ]
gb <- apply(g, 2L, function(col) as.integer(col > stats::median(col)))
# A Heywood case (and any non-PD smoothing) on this small binary example is
# incidental to the equivalence being checked; muffle only those specific
# warnings rather than blanket-suppressing every condition.
e_poly <- .muffle(EFA(gb, n_factors = 2, method = "ULS",
cor_method = "poly", rotation = "none"),
"efa_heywood", "efa_cor_smoothed")
e_tetra <- .muffle(EFA(gb, n_factors = 2, method = "ULS",
cor_method = "tetra", rotation = "none"),
"efa_heywood", "efa_cor_smoothed")
expect_equal(e_tetra$unrot_loadings, e_poly$unrot_loadings)
expect_equal(e_tetra$orig_R, e_poly$orig_R)
})
test_that("cor_method = 'tetra' rejects variables with more than two categories", {
expect_error(EFA(DOSPERT_raw, n_factors = 3, cor_method = "tetra"),
class = "efa_cor_not_binary")
})
test_that("a Frechet-bound polychoric pair refuses DWLS and withholds the sandwich SEs", {
x <- .degenerate_ordinal()
# The first pair's response table is at a Frechet bound, so it has no asymptotic variance (see
# test-polychoric.R). DWLS weights each element by the inverse of that variance, so it cannot be
# formed and the fit is refused rather than run on a weight invented from rounding noise -- which
# previously either dropped the pair from the solution or let it dominate, depending on the
# platform.
expect_error(
suppressWarnings(
efa_fit(x, n_factors = 2, estimator = "DWLS", cor_method = "poly", rotation = "none")),
class = "efa_dwls_degenerate_weight")
# The ULS sandwich consumes the same covariance. It still returns a fit -- the point estimates
# need no asymptotic covariance -- but the standard errors are withheld rather than reported from
# a contaminated meat matrix.
cl <- character()
sw <- withCallingHandlers(
suppressMessages(efa_fit(x, n_factors = 2, estimator = "ULS", cor_method = "poly",
se = "sandwich", rotation = "none")),
warning = function(w) { cl <<- c(cl, class(w)); invokeRestart("muffleWarning") })
expect_s3_class(sw, "efa")
expect_true(all(is.na(sw$SE$unrot_loadings)))
expect_true("efa_se_unreliable" %in% cl)
# The boundary pair is named, and reporting its correlation at the boundary makes this matrix
# indefinite, so it is smoothed. Both are asserted because both are consequences of the boundary
# estimate that a change to it would silently alter: at the old (lower) estimate this matrix was
# positive definite and no smoothing happened.
expect_true("efa_cor_boundary" %in% cl)
expect_true("efa_cor_smoothed" %in% cl)
# The degeneracy screen fires on this data, and does so on every platform: the variance reaches
# it as NA by construction rather than as a number whose size decided the branch.
expect_true("efa_acov_degenerate" %in% cl)
# Clean ordinal data raises no degeneracy warning.
cl_clean <- .warn_classes(suppressMessages(
.prepare_cor_input(DOSPERT_raw[stats::complete.cases(DOSPERT_raw), 1:6],
cor_method = "poly", acov = "diag", dwls = TRUE,
inform_from_data = FALSE)))
expect_false("efa_acov_degenerate" %in% cl_clean)
})
test_that("a continuity-corrected binary pair still refuses DWLS", {
# The correction repairs the point estimate of a binary pair with an empty cell, but not its
# asymptotic variance: the sandwich would treat the nudged counts as if they were the observed
# data, and simulated coverage of an interval built that way is not trustworthy. So a corrected
# pair is withheld exactly like a boundary pair, and DWLS refuses the data rather than weighting
# by a number it cannot justify. This is the test that pins that decision.
#
# Six binary items that are monotone in one another, so every pair's 2x2 table has one empty
# cell and every pair is corrected.
x <- vapply(1:6, function(k) as.integer((0:239L) %% 7L >= k), integer(240L))
colnames(x) <- paste0("i", 1:6)
poly <- suppressWarnings(.polychoric(x, acov = "diag"))
expect_true(all(poly$zero_corrected))
expect_true(all(is.na(poly$acov)))
expect_error(
suppressWarnings(
efa_fit(x, n_factors = 1, estimator = "DWLS", cor_method = "tetra", rotation = "none")),
class = "efa_dwls_degenerate_weight")
# ULS needs no asymptotic covariance, so the corrected matrix still fits -- which is the point
# of the correction: these data are analysable now, where the boundary estimate made every
# correlation 0.9999.
cl <- character()
fit <- withCallingHandlers(
suppressMessages(efa_fit(x, n_factors = 1, estimator = "ULS", cor_method = "tetra",
rotation = "none")),
warning = function(w) { cl <<- c(cl, class(w)); invokeRestart("muffleWarning") })
expect_s3_class(fit, "efa")
expect_true("efa_cor_zero_cell" %in% cl)
expect_false("efa_cor_boundary" %in% cl)
expect_true(all(abs(fit$orig_R[upper.tri(fit$orig_R)]) < 0.9999))
})
test_that(".warn_acov_degenerate flags an unusable asymptotic variance and passes clean ones", {
# A variance above 1 already implies a +/- 1 SE interval as wide as the whole [-1, 1] range
# of a correlation; non-finite and non-positive values are unusable a fortiori. Asserted on
# supplied vectors rather than on an estimate, so the gate is exercised exactly rather than
# through whichever side of it a fitted pair happens to land on.
expect_warning(.warn_acov_degenerate(c(a = 0.01, b = 12, c = 0.02)),
class = "efa_acov_degenerate")
expect_warning(.warn_acov_degenerate(c(a = 0.01, b = Inf, c = 0.02)),
class = "efa_acov_degenerate")
expect_warning(.warn_acov_degenerate(c(a = 0.01, b = NaN, c = 0.02)),
class = "efa_acov_degenerate")
expect_warning(.warn_acov_degenerate(c(a = 0.01, b = 0, c = 0.02)),
class = "efa_acov_degenerate")
expect_warning(.warn_acov_degenerate(c(a = 0.01, b = -1, c = 0.02)),
class = "efa_acov_degenerate")
# Ordinary variances pass, and an unnamed vector is reported positionally rather than failing
# on the missing labels.
expect_no_warning(.warn_acov_degenerate(c(a = 0.01, b = 0.5, c = 0.02)))
expect_warning(.warn_acov_degenerate(c(0.01, 12, 0.02)), class = "efa_acov_degenerate")
})
test_that("the degenerate-variance warning reads correctly for one and for several pairs", {
# Every bullet pluralises on the number of affected pairs, so both branches are recorded. The
# first bullet opens with cli::qty(); a space after that call renders as a second space behind
# the bullet marker, which is why it has none.
degenerate_msg <- function(v) {
w <- tryCatch(.warn_acov_degenerate(v), efa_acov_degenerate = function(w) w)
expect_s3_class(w, "efa_acov_degenerate")
conditionMessage(w)
}
expect_snapshot(cat(degenerate_msg(c(`v1-v2` = 0.01, `v1-v3` = -1))))
expect_snapshot(cat(degenerate_msg(c(`v1-v2` = NA_real_, `v1-v3` = -1))))
})
test_that("both acov requests screen the same pair-labelled asymptotic variances", {
# .prepare_cor_input() screens the variances once, before either consumer touches them, so a
# degenerate pair is reported identically whether it reaches the DWLS weights (acov = "diag")
# or the sandwich meat (acov = "full", whose diagonal is the same quantity). Captured from the
# screen itself: the value that decides the warning is undetermined for a boundary pair, so
# what is asserted is the routing -- that both paths screen, and screen the same labelled
# diagonal -- on clean data where the screen is silent either way.
seen <- list()
local_mocked_bindings(
.warn_acov_degenerate = function(acov_diag, labels = names(acov_diag)) {
seen[[length(seen) + 1L]] <<- acov_diag
invisible(NULL)
}
)
x <- DOSPERT_raw[stats::complete.cases(DOSPERT_raw), 1:5]
labels <- apply(utils::combn(colnames(x), 2L), 2L, paste, collapse = "-")
suppressMessages(.prepare_cor_input(x, cor_method = "poly", acov = "diag", dwls = TRUE,
inform_from_data = FALSE))
suppressMessages(.prepare_cor_input(x, cor_method = "poly", acov = "full", dwls = TRUE,
inform_from_data = FALSE))
expect_length(seen, 2L)
expect_named(seen[[1L]], labels)
expect_named(seen[[2L]], labels)
# The "full" path reaches the screen through diag(Gamma), which accumulates the same per-cell
# influences by a different route (scatter then crossprod) than the "diag" path's direct sum.
expect_equal(seen[[1L]], seen[[2L]], tolerance = 1e-6)
})
test_that("cor_method = 'poly' rejects unordered factor / character columns through efa_fit", {
set.seed(123)
N <- 400L
z1 <- stats::rnorm(N)
z2 <- 0.7 * z1 + sqrt(1 - 0.7^2) * stats::rnorm(N)
br <- stats::qnorm(c(.33, .66))
lab <- c("low", "mid", "high")
df <- data.frame(a = factor(lab[findInterval(z1, br) + 1L]), # unordered
b = factor(lab[findInterval(z2, br) + 1L]))
expect_error(
suppressMessages(efa_fit(df, n_factors = 1, cor_method = "poly")),
class = "efa_cor_unordered_factor")
})
test_that("method = 'DWLS' requires a polychoric asymptotic covariance", {
# a correlation matrix carries no raw data to estimate an asymptotic covariance from
expect_error(EFA(test_models$baseline$cormat, n_factors = 3, method = "DWLS", N = 500),
class = "efa_dwls_no_acov")
# a continuous correlation method produces no polychoric asymptotic covariance
expect_error(EFA(DOSPERT_raw, n_factors = 3, method = "DWLS", cor_method = "pearson"),
class = "efa_dwls_no_acov")
# the abort fires before fitting, regardless of bootstrap settings
expect_error(EFA(test_models$baseline$cormat, n_factors = 3, method = "DWLS", N = 500,
se = "np-boot"),
class = "efa_dwls_no_acov")
})
test_that("KMO() and BARTLETT() honour cor_method = 'poly'", {
# Six items rather than all thirty: what is asserted is the routing, an exact identity
# that holds for any item set, and six items make each polychoric matrix 15 pairs instead
# of 435. The rows are still the complete cases of the full data, so N is unchanged.
x <- DOSPERT_raw[stats::complete.cases(DOSPERT_raw), 1:6]
N <- nrow(x)
Rp <- .polychoric(x)$R
# cor_method = "poly" must produce exactly the result of passing that polychoric
# matrix in directly: this pins the routing through .prepare_cor_input(). The raw
# call infers N = nrow(x), the same N supplied with the matrix.
expect_equal(KMO(x, cor_method = "poly")$KMO, KMO(Rp)$KMO)
expect_equal(BARTLETT(x, cor_method = "poly")$chisq,
BARTLETT(Rp, N = N)$chisq)
})
test_that("KMO() and BARTLETT() with cor_method = 'poly' match an external polychoric reference", {
skip_on_cran()
skip_if_not_installed("psych")
x <- DOSPERT_raw[stats::complete.cases(DOSPERT_raw), ]
N <- nrow(x)
Rp <- suppressWarnings(psych::polychoric(x, correct = FALSE, global = FALSE)$rho)
# The two polychoric matrices agree to ~1e-4, so the suitability statistics agree
# to well within 1e-2.
expect_equal(KMO(x, cor_method = "poly")$KMO, psych::KMO(Rp)$MSA,
tolerance = 1e-2)
expect_equal(BARTLETT(x, cor_method = "poly")$chisq,
psych::cortest.bartlett(Rp, n = N)$chisq,
tolerance = 1e-2, ignore_attr = TRUE)
})
test_that("the simulation-based criteria reject poly / tetra with a classed condition", {
expect_error(CD(DOSPERT_raw, cor_method = "poly"),
class = "efa_cor_method_unsupported")
expect_error(PARALLEL(DOSPERT_raw, cor_method = "tetra"),
class = "efa_cor_method_unsupported")
expect_error(NEST(DOSPERT_raw, N = 200, cor_method = "poly"),
class = "efa_cor_method_unsupported")
# HULL derives its factor-search bound from an internal parallel analysis, so it
# rejects poly/tetra for the same reason.
expect_error(HULL(DOSPERT_raw, N = 200, cor_method = "tetra"),
class = "efa_cor_method_unsupported")
})
test_that("the poly rejection does not steer an ordinal analysis to Kendall's tau", {
# The remedy is read by someone who reached for a polychoric correlation because the
# items are ordinal. Kendall's tau is not on the Pearson scale the factor model
# parameterises (for bivariate-normal data tau = (2/pi) asin(rho)), so it is the most
# attenuated of the three and must not be offered here. The message is rendered from
# every caller of the shared helper, so two of the five are checked: one continuous
# reference criterion and efa_smt(), which reaches it with its own reason text.
# `error =`, not `condition =`: these functions also emit an informational message about
# computing correlations from raw data, which a condition handler would catch instead.
remedy <- function(expr) {
cnd <- tryCatch(expr, error = function(e) e)
expect_s3_class(cnd, "efa_cor_method_unsupported")
cli::ansi_strip(paste(conditionMessage(cnd), collapse = " "))
}
for (msg in c(remedy(efa_parallel(DOSPERT_raw, cor_method = "poly")),
remedy(efa_smt(DOSPERT_raw, cor_method = "poly")))) {
expect_match(msg, "pearson", fixed = TRUE)
expect_match(msg, "spearman", fixed = TRUE)
expect_no_match(msg, "kendall", fixed = TRUE)
}
})
test_that("cor_method = 'poly' honours `use` for missing data", {
g <- GRiPS_raw[stats::complete.cases(GRiPS_raw), ]
gm <- g
gm[1:5, 1] <- NA
# all.obs and everything do not delete missing data, so they error on it,
# matching the outcome of stats::cor() for the Pearson path.
expect_error(KMO(gm, cor_method = "poly", use = "all.obs"),
class = "efa_cor_na")
expect_error(KMO(gm, cor_method = "poly", use = "everything"),
class = "efa_cor_na")
# complete.obs listwise-deletes first, so the matrix equals the polychoric of
# the complete-case data.
R_listwise <- .polychoric(gm[stats::complete.cases(gm), ])$R
expect_equal(KMO(gm, cor_method = "poly", use = "complete.obs")$KMO,
KMO(R_listwise)$KMO)
})
test_that("cor_method = 'poly' uses pairwise-complete thresholds under missing data", {
skip_on_cran()
skip_if_not_installed("polycor")
set.seed(202)
N <- 2000
L <- chol(matrix(c(1, .6, .6, 1), 2L))
Z <- matrix(stats::rnorm(2 * N), N, 2L) %*% L
a <- findInterval(Z[, 1], stats::qnorm(c(.3, .6)))
b <- findInterval(Z[, 2], stats::qnorm(c(.25, .55, .8)))
# Missing-at-random: a is dropped more often where b is high, so b's pairwise-complete
# marginal differs from its full-column marginal. A pairwise polychoric must take its
# thresholds from each pair's own complete cases (as polycor does); using the full-column
# marginals instead would bias this estimate by ~0.06.
am <- a; am[(b >= 2) & (stats::runif(N) < 0.7)] <- NA
ours <- .polychoric(cbind(am, b))$R[1, 2]
ref <- suppressWarnings(polycor::polychor(am, b, ML = FALSE))
expect_equal(ours, ref, tolerance = 1e-3)
})
test_that("an asymptotic covariance over pairwise data reports the listwise override", {
g <- GRiPS_raw[stats::complete.cases(GRiPS_raw), ]
gm <- g; gm[1:5, 1] <- NA
# With missing data, an asymptotic covariance (here the DWLS weights) forces listwise
# deletion even though `use` asks for pairwise-complete estimation; that override is reported.
expect_message(
suppressWarnings(
.prepare_cor_input(gm, cor_method = "poly", acov = "diag",
use = "pairwise.complete.obs", inform_from_data = FALSE),
classes = "efa_cor_sparse_cells"
),
class = "efa_acov_listwise"
)
# Complete data needs no override, so nothing is reported.
expect_no_message(
suppressWarnings(
.prepare_cor_input(g, cor_method = "poly", acov = "diag",
use = "pairwise.complete.obs", inform_from_data = FALSE),
classes = "efa_cor_sparse_cells"
),
class = "efa_acov_listwise"
)
# A diagonal (DWLS-weight) covariance is an ordinal construct; requesting it for a Pearson
# correlation is rejected rather than silently producing no weights.
expect_error(
.prepare_cor_input(gm, cor_method = "pearson", acov = "diag",
use = "pairwise.complete.obs", inform_from_data = FALSE),
class = "efa_acov_unsupported"
)
})
test_that("an acov requested for a correlation matrix is ignored with a warning", {
set.seed(1)
R <- stats::cor(matrix(stats::rnorm(200L), ncol = 4L))
expect_warning(
.prepare_cor_input(R, N = 50, acov = "full", inform_from_data = FALSE),
class = "efa_acov_ignored"
)
})
test_that("an unsupported acov/cor_method is rejected before any listwise notice", {
set.seed(1)
dat <- matrix(stats::rnorm(150L), ncol = 3L)
dat[1:3, 1L] <- NA
# full + a rank correlation is unsupported; with missing data the abort must precede (and
# replace) the listwise-override notice rather than follow it.
expect_error(
.prepare_cor_input(dat, cor_method = "spearman", acov = "full",
use = "pairwise.complete.obs", inform_from_data = FALSE),
class = "efa_acov_unsupported"
)
})
test_that("N_FACTORS skips reference-based criteria under poly with an informative note", {
classes <- character()
withCallingHandlers(
N_FACTORS(GRiPS_raw, criteria = c("EKC", "HULL"), cor_method = "poly",
method = "ULS", suitability = FALSE),
warning = function(w) {
classes <<- c(classes, class(w))
invokeRestart("muffleWarning")
}
)
# HULL is skipped with the dedicated 'skipped' class, not reported as a generic
# failure; EKC still runs on the polychoric matrix.
expect_true("efa_criterion_skipped" %in% classes)
expect_false("efa_criterion_failed" %in% classes)
})
test_that("N_FACTORS skips SMT under poly because its normal-theory test is invalid", {
classes <- character()
out <- withCallingHandlers(
N_FACTORS(GRiPS_raw, criteria = c("EKC", "SMT"), cor_method = "poly",
method = "ULS", suitability = FALSE),
warning = function(w) {
classes <<- c(classes, class(w))
invokeRestart("muffleWarning")
}
)
# SMT's sequential model tests rest on the normal-theory ML chi-square, which is
# not valid for polychoric/tetrachoric correlations, so it is skipped (not run on
# an inappropriate matrix); EKC, a descriptive eigenvalue rule, still runs.
expect_true("efa_criterion_skipped" %in% classes)
expect_true("SMT" %in% names(out$not_run))
expect_false("SMT" %in% names(out$outputs))
expect_true("EKC" %in% names(out$outputs))
})
test_that("the polychoric bootstrap is reproducible and positive definite under resampling", {
g <- GRiPS_raw[stats::complete.cases(GRiPS_raw), ]
b1 <- EFA(g, n_factors = 1, method = "ULS", cor_method = "poly",
se = "np-boot", b_boot = 6, seed = 42)
b2 <- EFA(g, n_factors = 1, method = "ULS", cor_method = "poly",
se = "np-boot", b_boot = 6, seed = 42)
# Same seed -> the same bootstrap SEs, independent of how many replicates were fit.
expect_equal(b1$SE$unrot_loadings, b2$SE$unrot_loadings, tolerance = fp_tol)
# Replicate matrices are recomputed per resample (degenerate ones are dropped at
# the fit), so the surviving replicates yield finite SEs.
expect_true(all(is.finite(b1$SE$unrot_loadings)))
})
test_that("the DWLS polychoric bootstrap is reproducible and positive definite under resampling", {
g <- GRiPS_raw[stats::complete.cases(GRiPS_raw), ]
b1 <- suppressWarnings(EFA(g, n_factors = 1, method = "DWLS", cor_method = "poly",
se = "np-boot", b_boot = 6, seed = 42))
b2 <- suppressWarnings(EFA(g, n_factors = 1, method = "DWLS", cor_method = "poly",
se = "np-boot", b_boot = 6, seed = 42))
# Same seed -> the same bootstrap SEs; each replicate recomputes its own polychoric
# matrix and diagonal-ACOV weights, and the surviving replicates yield finite SEs.
expect_equal(b1$SE$unrot_loadings, b2$SE$unrot_loadings, tolerance = fp_tol)
expect_true(all(is.finite(b1$SE$unrot_loadings)))
})
test_that("the polychoric bootstrap on DOSPERT_raw completes (timed)", {
skip_on_cran()
skip_if_not_slow()
b_boot <- 200L
t <- system.time(
fit <- EFA(DOSPERT_raw, n_factors = 3, method = "ULS", cor_method = "poly",
se = "np-boot", b_boot = b_boot, seed = 1)
)
elapsed <- unname(t["elapsed"])
cli::cli_inform(
"Polychoric bootstrap: {b_boot} replicates on DOSPERT_raw in {round(elapsed, 1)}s ({round(elapsed / b_boot, 3)}s/replicate).")
expect_true(all(is.finite(fit$SE$unrot_loadings)))
})
Any scripts or data that you put into this service are public.
Add the following code to your website.
For more information on customizing the embed code, read Embedding Snippets.