Nothing
# Tests for efa_group(): per-group fitting at a common number of factors and the
# shared consensus/reference alignment. Deterministic rotations (varimax, promax)
# and identical-group invariants are used so no random-start behaviour is relied
# on. Conditions are asserted by class, not message text.
cmat <- test_models$baseline$cormat
p <- ncol(cmat)
# Three copies of one correlation matrix: the identical-group case, where every aligned
# solution must collapse onto the shared target and every congruence must be one. PAF and
# varimax are both deterministic and neither draws a random start, so one fit serves every
# block that reads it -- and a second one with the invariance verdict switched on, since
# that adds tables without changing anything that is fitted.
ident_bands <- list(a = cmat, b = cmat, c = cmat)
ident_fit <- efa_group(ident_bands, n_factors = 3, N = 500, rotation = "varimax")
ident_fit_inv <- efa_group(ident_bands, n_factors = 3, N = 500, rotation = "varimax",
invariance = TRUE)
# The three WJIV age bands cut to their twelve leading variables: genuinely different
# correlation matrices over one item set, which is what an order- or gauge-invariance claim
# has to be demonstrated on (identical groups would satisfy it trivially).
wj_trio <- list(a = WJIV_ages_6_8$cormat[1:12, 1:12],
b = WJIV_ages_14_19$cormat[1:12, 1:12],
c = WJIV_ages_20_39$cormat[1:12, 1:12])
wj_trio_N <- c(WJIV_ages_6_8$N, WJIV_ages_14_19$N, WJIV_ages_20_39$N)
test_that("efa_group fits every group at a common k and returns an efa_group object", {
mg <- ident_fit
expect_s3_class(mg, "efa_group")
expect_length(mg$loadings, 3)
expect_named(mg$loadings, c("a", "b", "c"))
expect_true(all(vapply(mg$efa, inherits, logical(1), "EFA")))
expect_true(all(vapply(mg$efa, function(f) as.numeric(f$settings$n_factors),
numeric(1)) == 3))
expect_identical(dim(mg$loadings[[1]]), c(p, 3L))
expect_identical(dim(mg$target), c(p, 3L))
expect_identical(mg$settings$alignment, "consensus")
expect_null(mg$Phi)
})
test_that("identical groups align to a shared consensus target", {
mg <- ident_fit
# With identical inputs every aligned solution collapses onto the target.
expect_equal(mg$loadings$a, mg$loadings$b, tolerance = 1e-6, ignore_attr = TRUE)
expect_equal(mg$loadings$a, mg$loadings$c, tolerance = 1e-6, ignore_attr = TRUE)
expect_equal(mg$loadings$a, mg$target, tolerance = 1e-6, ignore_attr = TRUE)
})
test_that("consensus alignment is invariant to the order of the groups", {
skip_on_cran()
# The consensus frame is only identified up to a global rotation of its factors, so
# without a fixed gauge every reported statistic would depend on which group seeds the
# iteration -- i.e. on the order the groups are supplied in. The gauge is the simple
# structure of the requested rotation (varimax here) evaluated on the consensus target,
# and it removes that dependence: three genuinely different WJIV age bands are aligned
# in every group order and must yield the same shared frame and statistics.
fit <- function(o) {
efa_group(wj_trio[o], n_factors = 2, N = wj_trio_N[o],
rotation = "varimax", invariance = TRUE)
}
# All six orders, not just the reversal: the centroid iteration is seeded by the
# first group supplied, so each order hands the gauge a different starting frame.
perms <- list(c(1, 2, 3), c(1, 3, 2), c(2, 1, 3),
c(2, 3, 1), c(3, 1, 2), c(3, 2, 1))
fits <- lapply(perms, fit)
mg1 <- fits[[1L]]
# Salience flags compared per unordered pair, item, and factor: the absolute
# difference and the flag are orientation-independent (only the signed diff flips
# when a pair is listed the other way round).
flag_key <- function(fl) {
pair <- vapply(seq_len(nrow(fl)), function(i) {
paste(sort(c(fl$group_1[i], fl$group_2[i])), collapse = "|")
}, character(1L))
o <- order(paste(pair, fl$indicator, fl$factor, sep = "|"))
list(abs_diff = fl$abs_diff[o], flagged = fl$flagged[o])
}
k1 <- flag_key(mg1$flags)
# The seeding group is surfaced by name so a frame can be reproduced; it is the
# first group supplied, so it changes with the order. The reference path has none.
expect_identical(
vapply(fits, function(f) f$settings$alignment_start, character(1L)),
vapply(perms, function(o) names(wj_trio)[o[1L]], character(1L))
)
# The residual is the consensus iteration's own convergence floor and nothing else,
# so the tolerances are set from it rather than from the reporting precision: the
# worst deviation observed over these six orders is ~3e-12 on the loadings. They are
# deliberately far tighter than the ~3e-5 a loosely converged centroid leaves behind,
# which is the scale at which a borderline salience flag could still flip.
for (i in seq_along(fits)[-1L]) {
mg2 <- fits[[i]]
info <- paste(names(wj_trio)[perms[[i]]], collapse = "")
# The gauge fixes the shared frame, so each group's aligned loadings and the
# shared target match however the groups are ordered.
for (g in c("a", "b", "c")) {
expect_equal(mg1$loadings[[g]], mg2$loadings[[g]], tolerance = 1e-8,
ignore_attr = TRUE, info = paste(info, g))
}
expect_equal(mg1$target, mg2$target, tolerance = 1e-8, ignore_attr = TRUE,
info = info)
# ... and hence every reported statistic. Matched congruences are indexed by group
# name, so they are directly comparable across orderings.
for (pair in list(c("a", "b"), c("a", "c"), c("b", "c"))) {
expect_equal(mg1$congruence$matched[pair[1L], pair[2L], ],
mg2$congruence$matched[pair[1L], pair[2L], ], tolerance = 1e-8,
info = paste(info, paste(pair, collapse = "-")))
}
k2 <- flag_key(mg2$flags)
expect_equal(k1$abs_diff, k2$abs_diff, tolerance = 1e-8, info = info)
expect_identical(k1$flagged, k2$flagged, info = info)
}
expect_null(efa_group(wj_trio[1:2], n_factors = 2, N = wj_trio_N[1:2],
rotation = "varimax",
reference_group = "a")$settings$alignment_start)
})
test_that("an unrotated consensus solution uses the principal-axes gauge", {
skip_on_cran()
# With no requested rotation there is no simple structure to borrow, so the shared
# frame is fixed by the principal axes of the target: t(M) %*% M diagonal with a
# decreasing diagonal and non-negative column sums. That gauge is order-invariant too.
fit <- function(o) {
efa_group(wj_trio[o], n_factors = 2, N = wj_trio_N[o], rotation = "none")
}
mg1 <- fit(c(1, 2, 3))
mg2 <- fit(c(3, 2, 1))
expect_identical(mg1$settings$alignment, "consensus")
G <- crossprod(unclass(mg1$target))
expect_lt(max(abs(G[upper.tri(G)])), 1e-8) # t(M) %*% M is diagonal
expect_false(is.unsorted(rev(diag(G)))) # ... with a decreasing diagonal
expect_true(all(colSums(unclass(mg1$target)) >= 0))
# ... and, as on the varimax path, the frame does not depend on the group order.
expect_equal(mg1$target, mg2$target, tolerance = 1e-8, ignore_attr = TRUE)
expect_equal(mg1$congruence$matched["a", "b", ],
mg2$congruence$matched["a", "b", ], tolerance = 1e-8)
})
# Best-|phi| column matching between two loading matrices. Needed because a group's OWN
# rotated solution and the shared target need not be in the same column order, so the
# question "is the shared frame the same kind of structure" is about the columns as a set.
# This is not what efa_group reports: `congruence` is positional (`diag(cij)`), since the
# groups have already been rotated into the shared frame by then.
matched_phi <- function(A, B) {
P <- abs(.tucker_congruence(unclass(A), unclass(B)))
used <- integer(0)
vapply(seq_len(ncol(P)), function(j) {
cand <- setdiff(seq_len(ncol(P)), used)
i <- cand[which.max(P[j, cand])]
used <<- c(used, i)
P[j, i]
}, numeric(1))
}
test_that("the consensus gauge follows the requested rotation criterion", {
skip_on_cran()
# The shared frame is put in the simple structure of the rotation that was asked for, not
# in a borrowed one. bifactorT is the sharpest case: its per-group solutions carry a
# general factor loading on every variable, which a varimax gauge destroys -- that gauge
# returned a 9/4/6 salience split with a leading column whose smallest |loading| was .14,
# and matched congruences against the per-group solutions of only .74-.81.
mg <- suppressWarnings(
efa_group(wj_trio, n_factors = 3, N = wj_trio_N,
rotation = "bifactorT", seed = 11))
tgt <- unclass(mg$target)
expect_identical(mg$settings$gauge, "bifactorT")
# a general column loading saliently on every variable, and two sparse group columns
expect_identical(sum(abs(tgt[, 1L]) >= .3), nrow(tgt))
expect_gt(min(abs(tgt[, 1L])), .3)
expect_true(all(colSums(abs(tgt[, -1L, drop = FALSE]) >= .3) < nrow(tgt) / 2))
# and a frame the per-group solutions actually agree with
for (g in names(mg$efa)) {
expect_gt(mean(matched_phi(tgt, mg$efa[[g]]$rot_loadings)), .90)
}
})
test_that("the criterion gauge is invariant to the order of the groups", {
skip_on_cran()
# Order-invariance is not specific to varimax: every orthogonal criterion depends on the
# loadings alone, so rotating M and rotating M %*% Q0 reach the same rotated matrix and
# the gauge undoes whichever Q0 the group order produced. The residual is the consensus
# iteration's own convergence floor, not the gauge's, so the tolerance is set from that.
fit <- function(o, rot) {
suppressWarnings(
efa_group(wj_trio[o], n_factors = 3, N = wj_trio_N[o],
rotation = rot, seed = 11))
}
for (rot in c("quartimax", "geominT", "bifactorT")) {
mg1 <- fit(c(1, 2, 3), rot)
mg2 <- fit(c(3, 2, 1), rot)
expect_equal(mg1$target, mg2$target, tolerance = 1e-6, ignore_attr = TRUE,
info = rot)
for (g in c("a", "b", "c")) {
expect_equal(mg1$loadings[[g]], mg2$loadings[[g]], tolerance = 1e-6,
ignore_attr = TRUE, info = paste(rot, g))
}
# These criteria are optimised iteratively rather than in closed form, so the gauge
# adds its own stopping slack on top of the consensus floor: the worst deviation
# observed here is 4e-9 (bifactorT), against 3e-12 on the varimax path above.
expect_equal(mg1$congruence$matched["a", "b", ],
mg2$congruence$matched["a", "b", ], tolerance = 1e-6, info = rot)
}
})
test_that("the gauge is equivariant: it undoes an arbitrary reorientation of the frame", {
skip_on_cran()
# The property the whole gauge rests on, tested directly on .consensus_gauge() rather than
# through efa_group(): gauging M and gauging a reoriented copy M %*% Q0 must land on the
# same frame, since that is exactly what a different group order hands over. It is not
# enough that the criterion's global optimum has this property -- the solver only
# approximates it, and it draws its random starts in whatever frame it is given, so two
# orientations explore different points. A five-factor frame with cross-loadings is where
# that bites: without the principal-axes pre-rotation the geomin gauge settled 0.1% above
# the global optimum for some orientations and not others, moving the frame by 0.15.
set.seed(4004)
p <- 20L; k <- 5L
M <- matrix(stats::rnorm(p * k, 0, 0.15), p, k)
M[cbind(seq_len(p), rep(seq_len(k), length.out = p))] <- stats::runif(p, 0.4, 0.85)
M <- M / sqrt(max(rowSums(M^2)) / 0.8)
canon <- function(L) {
ord <- order(colSums(L^2), decreasing = TRUE)
L <- L[, ord, drop = FALSE]
L %*% diag(.reflect_signs(L), nrow = ncol(L))
}
for (rot in c("varimax", "quartimax", "equamax", "geominT", "bentlerT", "bifactorT")) {
devs <- vapply(1:5, function(i) {
set.seed(700 + i)
Q0 <- qr.Q(qr(matrix(stats::rnorm(k * k), k, k)))
if (i %% 2L == 0L) Q0[, 1L] <- -Q0[, 1L] # cover det(Q0) = -1 as well
set.seed(11)
A <- canon(M %*% .consensus_gauge(M, rot, "orthogonal")$Q)
set.seed(11)
B <- canon((M %*% Q0) %*% .consensus_gauge(M %*% Q0, rot, "orthogonal")$Q)
max(abs(A - B))
}, numeric(1))
expect_lt(max(devs), 1e-6, label = paste0("max gauge deviation for ", rot))
}
})
test_that("a criterion parameter set on the fits also reaches the gauge", {
skip_on_cran()
# `delta` changes what geomin optimizes, so gauging at the default while the groups were
# rotated at another value would leave the shared frame in a different simple structure
# than the solutions it summarises. It can only travel by the rotate control: `delta` is
# efa_group()'s own salience-flag argument, so it can never arrive through the dots, and
# the fit settings do not record it. Kaiser normalization is the same kind of setting and
# is taken from the fit settings, where it is recorded.
fit <- function(...) {
suppressWarnings(efa_group(wj_trio, n_factors = 3, N = wj_trio_N,
rotation = "geominT", seed = 11, ...))
}
base <- fit()
wide <- fit(rotate_control = rotate_control(delta = 0.5))
expect_false(isTRUE(all.equal(unclass(base$target), unclass(wide$target),
tolerance = 1e-3)))
# directly on the gauge, so the assertion does not depend on the fits also moving
M <- unname(base$alignment$target)
expect_false(isTRUE(all.equal(
.consensus_gauge(M, "geominT", "orthogonal")$Q,
.consensus_gauge(M, "geominT", "orthogonal",
rotation_args = list(delta = 0.5))$Q,
tolerance = 1e-3)))
# normalization likewise reaches it, and from the fit settings rather than the control
expect_false(isTRUE(all.equal(
.consensus_gauge(M, "geominT", "orthogonal", normalize = TRUE)$Q,
.consensus_gauge(M, "geominT", "orthogonal", normalize = FALSE)$Q,
tolerance = 1e-3)))
})
test_that("a two-factor bifactor request falls back to the principal-axes gauge", {
skip_on_cran()
# The Jennrich-Bentler orthogonal bifactor criterion sums lambda_ij^2 lambda_il^2 over
# j != l with j, l >= 2, so with a single group factor the sum is empty and the criterion
# is identically zero: every rotation is a global optimum, the engine returns the identity,
# and the gauge would be a no-op leaving the frame wherever the iteration happened to stop
# (loadings moved by more than 1.0 between group orders). The principal-axes gauge takes
# over, which is both order-invariant and what the per-group solutions look like anyway.
fit <- function(o) {
suppressWarnings(
efa_group(wj_trio[o], n_factors = 2, N = wj_trio_N[o],
rotation = "bifactorT", seed = 11))
}
mg1 <- fit(c(1, 2, 3))
mg2 <- fit(c(3, 2, 1))
# the fallback is reported, since `rotation` alone does not reveal it
expect_identical(mg1$settings$gauge, "principal_axes")
G <- crossprod(unclass(mg1$target))
expect_lt(max(abs(G[upper.tri(G)])), 1e-8) # the principal-axes signature
expect_false(is.unsorted(rev(diag(G))))
expect_true(all(colSums(unclass(mg1$target)) >= 0))
expect_equal(mg1$target, mg2$target, tolerance = 1e-4, ignore_attr = TRUE)
})
test_that(".consensus_gauge falls back where no criterion identifies a frame", {
M <- matrix(c(0.8, 0.7, 0.6, 0.2, 0.1, -0.3, 0.5, 0.4), nrow = 4)
pa <- eigen(crossprod(M), symmetric = TRUE)$vectors
# a single factor: the engines require two, and SO(1) leaves only the sign gauge
one <- .consensus_gauge(M[, 1L, drop = FALSE], "quartimax", "orthogonal")
expect_identical(one$Q, diag(1))
expect_identical(one$gauge, "identity")
# nothing to borrow from an unrotated or an oblique request, and nothing to run for a
# rotation with no orthogonal engine of its own
for (args in list(list("none", "none"), list("promax", "oblique"),
list("not_a_rotation", "orthogonal"))) {
g <- .consensus_gauge(M, args[[1L]], args[[2L]])
expect_equal(g$Q, pa, info = args[[1L]])
expect_identical(g$gauge, "principal_axes", info = args[[1L]])
}
# ... whereas a criterion rotation really runs its engine. Orthogonality alone would not
# show that -- the principal-axes fallback is orthogonal too -- so check that the frame
# differs from the fallback and that it lowers the criterion the caller asked for.
g <- .consensus_gauge(M, "quartimax", "orthogonal")
expect_identical(g$gauge, "quartimax")
expect_equal(crossprod(g$Q), diag(ncol(M)), tolerance = 1e-10, ignore_attr = TRUE)
expect_false(isTRUE(all.equal(unname(g$Q), unname(pa))))
quartimax_crit <- function(L) -sum(L^4)
expect_lt(quartimax_crit(M %*% g$Q), quartimax_crit(M %*% pa))
})
test_that("the alignment method follows the rotation and reference_group", {
bands <- list(a = cmat, b = cmat)
expect_identical(
efa_group(bands, n_factors = 3, N = 500, rotation = "varimax")$settings$alignment,
"consensus")
expect_identical(
efa_group(bands, n_factors = 3, N = 500, rotation = "varimax",
reference_group = 1)$settings$alignment,
"reference")
})
test_that("reference alignment keeps the reference group fixed", {
bands <- list(a = cmat, b = cmat)
mg <- efa_group(bands, n_factors = 3, N = 500, rotation = "varimax",
reference_group = "b")
expect_identical(mg$settings$alignment, "reference")
expect_identical(mg$settings$reference_group, "b")
# The reference group's aligned loadings are its own rotated loadings, and are
# the shared target.
expect_equal(mg$loadings$b, mg$efa$b$rot_loadings, ignore_attr = TRUE)
expect_equal(mg$target, mg$efa$b$rot_loadings, ignore_attr = TRUE)
# Identical data -> the other group aligns exactly onto the reference.
expect_equal(mg$loadings$a, mg$loadings$b, tolerance = 1e-6, ignore_attr = TRUE)
})
test_that("an oblique rotation routes to reference-Procrustes with a note", {
bands <- list(a = cmat, b = cmat)
# The note fires and the requested rotation is not silently changed. The fit it announces
# is captured from that same call, so the routing is read off the run that was asserted on.
expect_message(
mg <- efa_group(bands, n_factors = 3, N = 500, rotation = "promax"),
class = "efa_group_oblique_reference"
)
expect_identical(mg$settings$alignment, "reference")
expect_identical(mg$settings$rotation, "promax")
expect_false(is.null(mg$Phi))
expect_length(mg$Phi, 2)
expect_identical(dim(mg$Phi[[1]]), c(3L, 3L))
})
test_that("a list of correlation matrices disables the bootstrap and threads N", {
b1 <- WJIV_ages_14_19$cormat[1:12, 1:12]
b2 <- WJIV_ages_20_39$cormat[1:12, 1:12]
mg <- suppressWarnings(
efa_group(list(young = b1, adult = b2), n_factors = 2,
N = c(WJIV_ages_14_19$N, WJIV_ages_20_39$N), rotation = "varimax"))
expect_identical(mg$settings$input_type, "cormat")
expect_false(mg$settings$can_bootstrap)
expect_equal(unname(mg$settings$N),
c(WJIV_ages_14_19$N, WJIV_ages_20_39$N))
expect_length(mg$loadings, 2)
expect_identical(dim(mg$loadings$young), c(12L, 2L))
})
test_that("a single factor aligns by sign only", {
g <- rep(c("g1", "g2"), length.out = nrow(GRiPS_raw))
mg <- suppressMessages(efa_group(GRiPS_raw, groups = g, n_factors = 1))
expect_identical(mg$settings$input_type, "raw")
expect_true(mg$settings$can_bootstrap)
expect_identical(ncol(mg$target), 1L)
# k = 1: the aligned loadings differ from the per-group unrotated loadings only
# by a global sign.
expect_equal(abs(mg$loadings$g1), abs(unclass(mg$efa$g1$unrot_loadings)),
tolerance = 1e-8, ignore_attr = TRUE)
})
test_that("unused factor levels in groups do not create phantom groups", {
g <- factor(rep(c("a", "b"), length.out = nrow(GRiPS_raw)),
levels = c("a", "b", "c"))
mg <- suppressMessages(efa_group(GRiPS_raw, groups = g, n_factors = 1))
expect_length(mg$loadings, 2)
expect_named(mg$loadings, c("a", "b"))
expect_named(mg$efa, c("a", "b"))
})
test_that("Tucker congruence matches psych::factor.congruence and is symmetric", {
skip_on_cran()
skip_if_not_installed("psych")
b1 <- WJIV_ages_14_19$cormat[1:12, 1:12]
b2 <- WJIV_ages_20_39$cormat[1:12, 1:12]
mg <- efa_group(list(young = b1, adult = b2), n_factors = 2,
N = c(WJIV_ages_14_19$N, WJIV_ages_20_39$N),
rotation = "varimax")
cong <- mg$congruence
# Full pairwise matrices, a groups x groups x factors matched diagonal, and no
# degeneracy on a regular fit.
expect_named(cong, c("matrices", "matched", "degenerate"))
expect_identical(dim(cong$matched), c(2L, 2L, 2L))
expect_false(any(cong$degenerate))
# The full factor-by-factor matrix equals psych's Tucker congruence to ~1e-12.
# psych rounds its return value to `digits`, so ask for full precision.
ref <- psych::factor.congruence(mg$loadings$young, mg$loadings$adult,
digits = 15)
expect_equal(cong$matrices$young$adult, ref, tolerance = 1e-12,
ignore_attr = TRUE)
# The matched diagonal is that matrix's diagonal.
expect_equal(cong$matched["young", "adult", ], diag(ref), tolerance = 1e-12,
ignore_attr = TRUE)
# The reverse pair is checked against an independent psych call with the groups
# swapped (not merely the transpose of the forward pair), so a dropped or wrong
# transpose would be caught.
ref_rev <- psych::factor.congruence(mg$loadings$adult, mg$loadings$young,
digits = 15)
expect_equal(cong$matrices$adult$young, ref_rev, tolerance = 1e-12,
ignore_attr = TRUE)
expect_equal(cong$matched["adult", "young", ], diag(ref_rev), tolerance = 1e-12,
ignore_attr = TRUE)
})
test_that("identical groups have matched congruence of one", {
mg <- ident_fit
# Matched-factor congruence between identical groups is 1 across every pair.
expect_equal(as.numeric(mg$congruence$matched), rep(1, 3 * 3 * 3),
tolerance = 1e-6)
# A group with itself is exactly congruent, factor by factor.
expect_equal(diag(mg$congruence$matrices$a$a), rep(1, 3), tolerance = 1e-12,
ignore_attr = TRUE)
expect_false(any(mg$congruence$degenerate))
})
test_that("a degenerate factor yields NA congruence and a flag, not an error", {
set.seed(123)
clean <- matrix(stats::rnorm(12), nrow = 6, ncol = 2)
degen <- clean
degen[, 2] <- 1e-14 # an all-near-zero factor
# The near-zero factor must not abort the run.
cong <- .efa_group_congruence(list(g1 = clean, g2 = degen))
# Every pair touching the degenerate group is NA and flagged in both directions.
expect_true(all(is.na(cong$matched["g2", , ])))
expect_true(all(is.na(cong$matched[, "g2", ])))
expect_true(all(is.na(cong$matrices$g1$g2)))
expect_true(all(is.na(cong$matrices$g2$g1)))
expect_true(cong$degenerate["g1", "g2"])
expect_true(cong$degenerate["g2", "g1"])
expect_true(cong$degenerate["g2", "g2"])
# The clean group's self-pair is unaffected and finite.
expect_false(cong$degenerate["g1", "g1"])
expect_equal(diag(cong$matrices$g1$g1), rep(1, 2), tolerance = 1e-12,
ignore_attr = TRUE)
expect_true(all(is.finite(cong$matched["g1", "g1", ])))
})
test_that("efa_group guards its inputs with classed conditions", {
# Same item count, different names -> unequal items.
m1 <- cmat[1:5, 1:5]
m2 <- m1
dimnames(m2) <- list(paste0("z", 1:5), paste0("z", 1:5))
expect_error(efa_group(list(m1, m2), n_factors = 1, N = 500),
class = "efa_group_unequal_items")
# Fewer than two groups.
expect_error(efa_group(list(only = cmat), n_factors = 3, N = 500),
class = "efa_group_too_few_groups")
# Under-identified model (p = 4, k = 2 -> df = -1).
m4 <- cmat[1:4, 1:4]
expect_error(efa_group(list(m4, m4), n_factors = 2, N = 500),
class = "efa_group_under_identified")
# A correlation matrix cannot be split into groups.
expect_error(efa_group(cmat, groups = rep(1:2, length.out = ncol(cmat)),
n_factors = 2, N = 500),
class = "efa_group_cormat_needs_list")
# Single raw data set without a grouping vector.
expect_error(efa_group(GRiPS_raw, n_factors = 1),
class = "efa_group_needs_groups")
# A grouping vector supplied together with a list.
expect_error(efa_group(list(cmat, cmat), groups = c(1, 2), n_factors = 3, N = 500),
class = "efa_group_groups_with_list")
# A list mixing raw data and a correlation matrix.
raw <- as.data.frame(GRiPS_raw[1:50, ])
cor_raw <- stats::cor(GRiPS_raw, use = "pairwise")
expect_error(efa_group(list(raw, cor_raw), n_factors = 1),
class = "efa_group_mixed_input")
# An unknown reference group.
expect_error(efa_group(list(a = cmat, b = cmat), n_factors = 3, N = 500,
reference_group = "nope"),
class = "efa_group_bad_reference")
# Duplicated group names in a list.
expect_error(efa_group(list(a = cmat, a = cmat), n_factors = 3, N = 500),
class = "efa_group_duplicate_groups")
# A confidence level outside (0, 1).
expect_error(efa_group(list(a = cmat, b = cmat), n_factors = 3, N = 500, ci = 1.5),
class = "efa_group_bad_ci")
# N length neither 1 nor one-per-group (correlation-matrix input).
expect_error(efa_group(list(a = cmat, b = cmat), n_factors = 3, N = c(100, 200, 300)),
class = "efa_group_bad_n")
# A single bootstrap replicate has no spread to build an interval from.
expect_error(efa_group(list(a = cmat, b = cmat), n_factors = 3, N = 500, b_boot = 1),
class = "efa_group_bad_b_boot")
# A grouping vector shorter than the data it splits.
expect_error(efa_group(GRiPS_raw, groups = rep(1:2, length.out = 50), n_factors = 1),
class = "efa_group_groups_length")
# A list element that is neither a matrix nor a data frame.
expect_error(efa_group(list(a = cmat, b = 1:5), n_factors = 3, N = 500),
class = "efa_group_input")
# Neither raw data with `groups` nor a list of per-group data sets.
expect_error(efa_group(1:5, n_factors = 3, N = 500),
class = "efa_group_input")
# A misspelled argument in the dots is refused before any group is fitted, so it is
# not reported as a statistical failure of whichever group happened to be fitted first.
expect_error(efa_group(list(a = cmat, b = cmat), n_factors = 3, N = 500, bogus = 1),
class = "efa_unused_dots")
# the guard reads the names only, so the rejection wins over evaluating the value
expect_error(efa_group(list(a = cmat, b = cmat), n_factors = 3, N = 500,
bogus = stop("must not be evaluated")),
class = "efa_unused_dots")
})
test_that("a failing group names the group and hands the cause on unaltered", {
# The re-label carries the cause as the condition's parent, which rlang renders in full
# under "Caused by error:" with its own bullets intact. Repeating the parent's message as a
# bullet of this one used to flatten it: every marker of a multi-bullet cause -- most of
# efa_fit()'s input guards raise one -- was rendered inline, mid-sentence.
e <- tryCatch(efa_group(list(a = cmat, b = cmat), n_factors = 3, N = 500,
estimator = "DWLS"),
efa_group_fit_failed = function(e) e)
expect_s3_class(e, "efa_group_fit_failed")
# The cause keeps its own class, so a caller can still dispatch on it.
expect_s3_class(e$parent, "efa_dwls_no_acov")
# ... and it is still readable from the message, which rlang builds from the whole chain.
expect_snapshot(cat(conditionMessage(e)))
})
test_that("efa_group forwards genuine efa_fit arguments and rotation extras", {
# the guard above must not refuse what the per-group fits legitimately consume
expect_s3_class(
efa_group(list(a = cmat, b = cmat), n_factors = 3, N = 500,
estimator = "ML", rotation = "oblimin", maxit = 750),
"efa_group"
)
})
test_that("rows with a missing group value are dropped with a classed warning", {
x <- GRiPS_raw[1:200, ]
g <- rep(c("a", "b"), each = 100)
g[1:2] <- NA
expect_warning(mg <- efa_group(x, groups = g, n_factors = 1),
class = "efa_group_na_group")
# the two rows are dropped, not assigned to a phantom third group
expect_length(mg$loadings, 2)
expect_named(mg$loadings, c("a", "b"))
})
# Bootstrap percentile CIs for the between-group Tucker congruences. GRiPS_raw is well
# conditioned, so every replicate should fit; a genuine (not identical-group) split
# keeps the congruences comfortably below the 1.0 ceiling so the point estimate sits
# inside the percentile interval.
test_that("a bootstrap adds percentile CIs to the matched congruences", {
g <- rep(c("g1", "g2"), length.out = nrow(GRiPS_raw))
mg <- suppressMessages(suppressWarnings(
efa_group(GRiPS_raw, groups = g, n_factors = 2, rotation = "varimax",
estimator = "PAF", b_boot = 60, seed = 2024)))
cong <- mg$congruence
expect_named(cong, c("matrices", "matched", "degenerate",
"matched_se", "matched_ci", "n_boot"))
expect_identical(dim(cong$matched_ci$lower), c(2L, 2L, 2L))
expect_identical(dim(cong$matched_ci$upper), c(2L, 2L, 2L))
expect_identical(dim(cong$matched_se), c(2L, 2L, 2L))
expect_identical(dimnames(cong$matched_ci$lower),
list(c("g1", "g2"), c("g1", "g2"), c("F1", "F2")))
# Every replicate fits on well-conditioned data.
expect_identical(cong$n_boot, 60L)
expect_identical(mg$settings$b_boot, 60L)
expect_identical(mg$settings$ci, 0.95)
# A valid interval everywhere; a self-pair is exactly congruent with itself.
expect_true(all(cong$matched_ci$lower <= cong$matched_ci$upper))
expect_equal(diag(cong$matched_ci$lower[, , 1]), c(1, 1), ignore_attr = TRUE)
expect_equal(diag(cong$matched_ci$upper[, , 1]), c(1, 1), ignore_attr = TRUE)
})
test_that("the bootstrap CIs bracket the point congruence", {
skip_on_cran()
g <- rep(c("g1", "g2"), length.out = nrow(GRiPS_raw))
mg <- suppressMessages(suppressWarnings(
efa_group(GRiPS_raw, groups = g, n_factors = 2, rotation = "varimax",
estimator = "PAF", b_boot = 300, seed = 2024)))
pt <- mg$congruence$matched
lo <- mg$congruence$matched_ci$lower
up <- mg$congruence$matched_ci$upper
# The point estimate lies inside the percentile interval (a small tolerance guards
# the Monte Carlo edge; the congruences here are ~0.97, comfortably below 1).
expect_true(all(lo - 0.01 <= pt & pt <= up + 0.01, na.rm = TRUE))
# Off-diagonal intervals are non-degenerate; the self-pairs are the [1, 1] ceiling.
expect_true(up["g1", "g2", 1] > lo["g1", "g2", 1])
expect_equal(as.numeric(pt[cbind(1:2, 1:2, 1)]), c(1, 1), tolerance = 1e-8)
})
test_that("np-boot congruence CIs are reproducible at 1 vs 2 workers", {
skip_on_cran()
skip_if_not_slow()
# The replicate fits are parallelised across workers inside each per-group EFA() with
# future.apply (future.seed = TRUE), while efa_group's re-alignment is deterministic,
# so a fixed `seed` must return the same intervals regardless of the number of
# workers. The multisession workers are fresh R processes that load the installed
# package, so run this under devtools::check() / after devtools::install() for the
# worker fits to match the main process. (multicore is unavailable on Windows.)
old_plan <- future::plan()
on.exit(future::plan(old_plan), add = TRUE)
g <- rep(c("g1", "g2"), length.out = nrow(GRiPS_raw))
run <- function() suppressMessages(suppressWarnings(
efa_group(GRiPS_raw, groups = g, n_factors = 2, rotation = "varimax",
estimator = "PAF", b_boot = 12, seed = 2024)))
future::plan(future::sequential)
one <- run()
future::plan(future::multisession, workers = 2)
two <- run()
expect_equal(one$congruence$matched_ci, two$congruence$matched_ci, tolerance = 1e-10)
expect_equal(one$congruence$matched_se, two$congruence$matched_se, tolerance = 1e-10)
expect_identical(one$congruence$n_boot, two$congruence$n_boot)
})
test_that("a supplied seed makes the CIs reproducible and restores the RNG", {
g <- rep(c("g1", "g2"), length.out = nrow(GRiPS_raw))
boot <- function() suppressMessages(suppressWarnings(
efa_group(GRiPS_raw, groups = g, n_factors = 2, rotation = "varimax",
estimator = "PAF", b_boot = 30, seed = 99)))
set.seed(1)
state_before <- get(".Random.seed", envir = globalenv(), inherits = FALSE)
one <- boot()
# The seeded bootstrap leaves the caller's stream untouched.
expect_identical(state_before,
get(".Random.seed", envir = globalenv(), inherits = FALSE))
two <- boot()
expect_equal(one$congruence$matched_ci, two$congruence$matched_ci)
# the seed is part of the record, so a saved object says how it was seeded
expect_equal(one$settings$seed, 99)
expect_null(suppressMessages(suppressWarnings(
efa_group(GRiPS_raw, groups = g, n_factors = 2, rotation = "varimax",
estimator = "PAF")))$settings$seed)
})
test_that("a supplied seed covers the per-group fits without a bootstrap", {
# `seed` applies whether or not congruence intervals are requested: the per-group fits
# are stochastic on their own when the rotation draws random starts. simplimax is the
# most multimodal criterion, so two seeded runs agreeing is meaningful.
g <- rep(c("g1", "g2"), length.out = nrow(GRiPS_raw))
run <- function() suppressMessages(suppressWarnings(
efa_group(GRiPS_raw, groups = g, n_factors = 2, rotation = "simplimax",
estimator = "PAF", seed = 7)))
set.seed(1)
state_before <- get(".Random.seed", envir = globalenv(), inherits = FALSE)
one <- run()
# The restore-on-exit contract holds on the non-bootstrap path too.
expect_identical(state_before,
get(".Random.seed", envir = globalenv(), inherits = FALSE))
two <- run()
expect_equal(one$loadings, two$loadings)
expect_equal(one$congruence$matched, two$congruence$matched)
})
test_that("bootstrap replicate failures are dropped with a classed warning", {
set.seed(1)
p <- 6L; k <- 2L; b <- 10L
target <- matrix(stats::rnorm(p * k), p, k)
jitter_cube <- function() {
a <- array(NA_real_, c(p, k, b))
for (i in seq_len(b)) a[, , i] <- target + matrix(stats::rnorm(p * k, sd = 0.05), p, k)
a
}
c1 <- jitter_cube()
c2 <- jitter_cube()
c2[, , c(2, 5, 8)] <- NA_real_ # three unfittable replicates in group 2
cubes <- list(g1 = c1, g2 = c2)
expect_warning(
res <- .efa_group_boot_congruence(cubes, target, "orthogonal", 0.95),
class = "efa_group_boot_failed"
)
# Only the replicates complete in both groups are used.
expect_identical(res$n_boot, 7L)
expect_identical(dim(res$matched_ci$lower), c(2L, 2L, 2L))
# Every replicate failing is a hard error, not an empty interval.
all_na <- list(g1 = array(NA_real_, c(p, k, b)), g2 = array(NA_real_, c(p, k, b)))
expect_error(
.efa_group_boot_congruence(all_na, target, "orthogonal", 0.95),
class = "efa_group_boot_all_failed"
)
})
test_that("a correlation-matrix input skips the bootstrap with a warning", {
b1 <- WJIV_ages_14_19$cormat[1:12, 1:12]
b2 <- WJIV_ages_20_39$cormat[1:12, 1:12]
expect_warning(
mg <- efa_group(list(young = b1, adult = b2), n_factors = 2,
N = c(WJIV_ages_14_19$N, WJIV_ages_20_39$N),
rotation = "varimax", b_boot = 50),
class = "efa_group_boot_unavailable"
)
# No interval fields are added on the point-estimate-only path.
expect_named(mg$congruence, c("matrices", "matched", "degenerate"))
expect_identical(mg$settings$b_boot, 0L)
})
test_that("a supplied se is dropped with a classed warning", {
# efa_group manages the SE method itself (bootstrap via b_boot); a stray se must not
# trigger an unrequested, unseeded per-group bootstrap or leave payload in out$efa.
# `se` is a declared formal, so it reaches this path whether or not `seed` is given:
# every formal precedes `...`, so a dots-only `se` is partially matched to `seed` and
# aborts in the `seed` check instead.
g <- rep(c("g1", "g2"), length.out = nrow(GRiPS_raw))
expect_warning(
mg <- suppressMessages(
efa_group(GRiPS_raw, groups = g, n_factors = 1, se = "np-boot")),
class = "efa_group_se_ignored"
)
expect_null(mg$efa$g1$SE)
expect_null(mg$efa$g1$replicates)
expect_named(mg$congruence, c("matrices", "matched", "degenerate"))
# An explicit `seed` forces exact matching, and a requested bootstrap makes efa_group
# set `se` for its own replicate fits; both still drop the supplied value.
expect_warning(
suppressMessages(
efa_group(GRiPS_raw, groups = g, n_factors = 1, se = "np-boot", seed = 1)),
class = "efa_group_se_ignored"
)
expect_warning(
mg_boot <- suppressMessages(
efa_group(GRiPS_raw, groups = g, n_factors = 1, se = "np-boot", b_boot = 2,
seed = 1)),
class = "efa_group_se_ignored"
)
expect_null(mg_boot$efa$g1$SE)
expect_null(mg_boot$efa$g1$replicates)
expect_identical(mg_boot$settings$b_boot, 2L)
})
test_that("a delta given with a geomin rotation reports which reading was applied", {
# `delta` names both the salience threshold and the geomin criterion parameter, and the
# formal takes the name. The criterion parameter can therefore only be set through
# rotate_control(); the warning gives the reading applied and the route to the other one.
expect_warning(
mg <- suppressMessages(
efa_group(wj_trio, n_factors = 3, N = wj_trio_N, rotation = "geominT",
delta = 0.001, seed = 1)),
class = "efa_group_delta_ambiguous"
)
expect_identical(mg$settings$delta, 0.001)
# The reading the warning states: the flag threshold moves, the rotation does not.
base <- suppressMessages(
efa_group(wj_trio, n_factors = 3, N = wj_trio_N, rotation = "geominT", seed = 1))
expect_equal(mg$loadings, base$loadings)
expect_true(all(mg$diffs$n_flagged > base$diffs$n_flagged))
# The other reading, which only rotate_control() can express, does move the rotation.
rc <- suppressMessages(
efa_group(wj_trio, n_factors = 3, N = wj_trio_N, rotation = "geominT", seed = 1,
rotate_control = rotate_control(delta = 0.001)))
expect_identical(rc$settings$delta, 0.1)
# Movement, not its size: the criterion optimum at this delta is ill-conditioned, so the
# threshold only has to clear numerical noise (~1e-8), not the 0.024 measured here.
expect_gt(max(abs(unlist(rc$loadings) - unlist(base$loadings))), 1e-3)
# Silent where the two readings cannot be confused: a rotation without a `delta`
# criterion parameter, and a `delta` left at its default.
expect_no_warning(
suppressMessages(
efa_group(wj_trio, n_factors = 3, N = wj_trio_N, rotation = "varimax",
delta = 0.001, seed = 1)),
class = "efa_group_delta_ambiguous"
)
expect_no_warning(
suppressMessages(
efa_group(wj_trio, n_factors = 3, N = wj_trio_N, rotation = "geominT", seed = 1)),
class = "efa_group_delta_ambiguous"
)
})
test_that("the raw-data correlation note is given once, not once per group", {
# Every group is cut from one `x`, so the note the per-group efa_fit() calls raise states
# one fact about one input. It stays a catchable classed condition.
g <- rep(c("g1", "g2", "g3"), length.out = nrow(GRiPS_raw))
n_notes <- 0L
withCallingHandlers(
efa_group(GRiPS_raw, groups = g, n_factors = 1),
efa_cor_from_data = function(cnd) {
n_notes <<- n_notes + 1L
invokeRestart("muffleMessage")
},
message = function(cnd) invokeRestart("muffleMessage")
)
expect_identical(n_notes, 1L)
})
# Cross-group loading differences, per-item salience flags, and the approximate-invariance
# verdict. The table builders are exercised directly on hand-built aligned loadings (so an
# injected non-invariant item is fully controlled), and the classifier against known phi.
test_that(".invariance_band maps congruences to the Lorenzo-Seva & ten Berge bands", {
expect_identical(
.invariance_band(c(0.98, 0.95, 0.90, 0.85, 0.8499, 0.50, NA)),
c("equal", "equal", "fair", "fair", "incongruent", "incongruent", NA)
)
})
test_that(".efa_group_diffs flags an injected non-invariant loading and summarises the pair", {
L1 <- matrix(c(0.70, 0.65, 0.10, 0.15,
0.12, 0.10, 0.72, 0.68), nrow = 4, ncol = 2,
dimnames = list(paste0("i", 1:4), c("F1", "F2")))
L2 <- L1
L2["i2", "F1"] <- L1["i2", "F1"] + 0.25 # one salient non-invariant cell
loadings <- list(g1 = L1, g2 = L2)
dd <- .efa_group_diffs(loadings, delta = 0.10)
# Flag table: one row per item x factor, exactly the injected cell flagged.
expect_named(dd$flags, c("group_1", "group_2", "indicator", "factor", "diff",
"abs_diff", "flagged", "ci_lower", "ci_upper", "ci_excludes_0"))
expect_identical(nrow(dd$flags), 8L)
expect_equal(sum(dd$flags$flagged), 1)
row <- dd$flags[dd$flags$flagged, ]
expect_identical(row$indicator, "i2")
expect_identical(row$factor, "F1")
expect_equal(row$abs_diff, 0.25)
expect_equal(row$diff, -0.25) # g1 - g2
# No bootstrap supplied -> the interval columns are NA.
expect_true(all(is.na(dd$flags$ci_lower)))
expect_true(all(is.na(dd$flags$ci_upper)))
expect_true(all(is.na(dd$flags$ci_excludes_0)))
# Diffs summary: one row for the single pair.
expect_named(dd$diffs, c("group_1", "group_2", "mean_abs_diff", "median_abs_diff",
"min_abs_diff", "max_abs_diff", "rmse", "n_flagged"))
expect_identical(nrow(dd$diffs), 1L)
expect_identical(dd$diffs$group_1, "g1")
expect_identical(dd$diffs$group_2, "g2")
expect_equal(dd$diffs$n_flagged, 1)
expect_equal(dd$diffs$max_abs_diff, 0.25)
expect_equal(dd$diffs$mean_abs_diff, 0.25 / 8)
# delta is a movable salience threshold: a larger one clears the flag, a smaller one keeps it.
expect_equal(sum(.efa_group_diffs(loadings, delta = 0.30)$flags$flagged), 0)
expect_equal(sum(.efa_group_diffs(loadings, delta = 0.20)$flags$flagged), 1)
})
test_that(".efa_group_diffs pairs each flag with whether the bootstrap CI excludes zero", {
L1 <- matrix(c(0.70, 0.65, 0.10, 0.15,
0.12, 0.10, 0.72, 0.68), nrow = 4, ncol = 2,
dimnames = list(paste0("i", 1:4), c("F1", "F2")))
loadings <- list(g1 = L1, g2 = L1)
# A synthetic difference-CI cube (groups x groups x items x factors) for the g1-g2 pair:
# most cells bracket zero; (i2, F1) is a strictly positive interval and (i3, F2) a strictly
# negative one, so both exclude zero.
gn <- c("g1", "g2"); it <- paste0("i", 1:4); fn <- c("F1", "F2")
lower <- array(NA_real_, dim = c(2, 2, 4, 2), dimnames = list(gn, gn, it, fn))
upper <- lower
lo_slice <- matrix(-0.05, 4, 2); up_slice <- matrix(0.05, 4, 2)
lo_slice[2, 1] <- 0.10; up_slice[2, 1] <- 0.30 # (i2, F1): excludes 0 (positive)
lo_slice[3, 2] <- -0.30; up_slice[3, 2] <- -0.10 # (i3, F2): excludes 0 (negative)
lower[1, 2, , ] <- lo_slice
upper[1, 2, , ] <- up_slice
diff_ci <- list(lower = lower, upper = upper)
fl <- .efa_group_diffs(loadings, delta = 0.10, diff_ci = diff_ci)$flags
expect_equal(sum(fl$ci_excludes_0), 2)
i2F1 <- fl$indicator == "i2" & fl$factor == "F1"
i3F2 <- fl$indicator == "i3" & fl$factor == "F2"
expect_true(fl$ci_excludes_0[i2F1])
expect_true(fl$ci_excludes_0[i3F2])
expect_equal(fl$ci_lower[i2F1], 0.10)
expect_equal(fl$ci_upper[i2F1], 0.30)
# A cell whose interval brackets zero is not marked.
expect_false(fl$ci_excludes_0[fl$indicator == "i1" & fl$factor == "F1"])
})
test_that(".efa_group_invariance reads the verdict conservatively off the CI lower bound", {
gn <- c("g1", "g2"); fn <- c("F1", "F2")
matched <- array(NA_real_, dim = c(2, 2, 2), dimnames = list(gn, gn, fn))
matched[1, 2, ] <- c(0.99, 0.97); matched[2, 1, ] <- c(0.99, 0.97)
matched[1, 1, ] <- 1; matched[2, 2, ] <- 1
lower <- array(NA_real_, dim = c(2, 2, 2), dimnames = list(gn, gn, fn))
# F1's lower bound stays in the "equal" band; F2's drops into "fair" though its point is "equal".
lower[1, 2, ] <- c(0.96, 0.88); lower[2, 1, ] <- c(0.96, 0.88)
lower[1, 1, ] <- 1; lower[2, 2, ] <- 1
inv <- .efa_group_invariance(list(matched = matched,
matched_ci = list(lower = lower)))
expect_named(inv, c("group_1", "group_2", "factor", "phi", "phi_lower", "verdict"))
expect_identical(nrow(inv), 2L) # one pair x two factors
expect_identical(inv$verdict[inv$factor == "F1"], "equal")
expect_identical(inv$verdict[inv$factor == "F2"], "fair") # conservative: point 0.97 would be "equal"
expect_equal(inv$phi[inv$factor == "F2"], 0.97)
expect_equal(inv$phi_lower[inv$factor == "F2"], 0.88)
# Without a bootstrap the verdict is point-based and phi_lower is left NA.
inv_pt <- .efa_group_invariance(list(matched = matched))
expect_identical(inv_pt$verdict, c("equal", "equal"))
expect_true(all(is.na(inv_pt$phi_lower)))
# A degenerate (NA) congruence yields an NA verdict rather than "incongruent".
matched_deg <- matched
matched_deg[1, 2, 2] <- NA; matched_deg[2, 1, 2] <- NA
inv_deg <- .efa_group_invariance(list(matched = matched_deg))
expect_true(is.na(inv_deg$verdict[inv_deg$factor == "F2"]))
})
test_that("efa_group always returns diffs/flags and gates the invariance verdict", {
mg <- ident_fit_inv
expect_s3_class(mg$diffs, "data.frame")
expect_s3_class(mg$flags, "data.frame")
expect_s3_class(mg$invariance, "data.frame")
# choose(3, 2) = 3 pairs; the invariance table adds a row per factor.
expect_identical(nrow(mg$diffs), 3L)
expect_identical(nrow(mg$invariance), 9L)
expect_identical(nrow(mg$flags), 3L * p * 3L)
# Identical groups: nothing salient, congruence 1, every factor "equal".
expect_equal(sum(mg$flags$flagged), 0)
expect_true(all(mg$diffs$n_flagged == 0))
expect_true(all(mg$invariance$verdict == "equal"))
expect_equal(mg$invariance$phi, rep(1, 9), tolerance = 1e-6)
# No bootstrap -> the interval-based columns are NA.
expect_true(all(is.na(mg$invariance$phi_lower)))
expect_true(all(is.na(mg$flags$ci_excludes_0)))
expect_equal(mg$settings$delta, 0.1)
expect_true(mg$settings$invariance)
# The verdict is gated: off by default, and diffs/flags are still present.
mg0 <- ident_fit
expect_null(mg0$invariance)
expect_false(mg0$settings$invariance)
expect_s3_class(mg0$diffs, "data.frame")
expect_s3_class(mg0$flags, "data.frame")
})
test_that("a bootstrap fills the flag CIs and the point difference sits inside them", {
skip_on_cran()
# An oblique rotation routes to the reference-Procrustes path. The displayed diff (from the
# aligned point loadings) and the bootstrap difference CI (from the re-aligned replicate
# cubes) must share one coordinate frame, so every point diff must lie inside its interval.
g <- rep(c("g1", "g2"), length.out = nrow(GRiPS_raw))
mg <- suppressMessages(suppressWarnings(
efa_group(GRiPS_raw, groups = g, n_factors = 2, rotation = "promax",
estimator = "PAF", b_boot = 60, seed = 2024)))
expect_identical(mg$settings$alignment, "reference")
fl <- mg$flags
# The bootstrap populates the interval columns.
expect_false(all(is.na(fl$ci_lower)))
expect_false(all(is.na(fl$ci_excludes_0)))
# Every point-estimate difference lies within its own percentile interval (a small
# tolerance guards the Monte Carlo edge; a genuine frame offset would push it far outside).
expect_true(all(fl$ci_lower - 0.02 <= fl$diff & fl$diff <= fl$ci_upper + 0.02,
na.rm = TRUE))
})
# ---- print / format / plot -------------------------------------------------
# scrub_num, plus a mask for the salience-flag count `n_flagged / (p*k)`: n_flagged is a hard
# threshold count over BLAS-sensitive aligned loadings, so it can drift by one across
# platforms, and scrub_num leaves it verbatim (it carries no decimal point). Pin the layout
# and wording, not the fragile count.
#
# The mask has to swallow the whitespace ahead of the count as well, and replace it with a
# single space: the count is printed right-aligned in its column, so a drift that changes its
# NUMBER OF DIGITS (6/12 to 12/12) moves the padding rather than the digits and would otherwise
# still change the scrubbed line. scrub_num cannot absorb that padding on its own -- it only
# eats leading whitespace for tokens that carry a decimal point.
scrub_group <- function(lines) sub("\\s*[0-9]+/[0-9]+", " <flagged>", scrub_num(lines))
# Two full WJIV age bands are the standard report fixture: two genuinely different groups
# over the whole item set, so every section of the printout has something to say. varimax
# draws no random start and the consensus gauge is closed form, so the fit is deterministic
# and the blocks below share one -- with the invariance verdict on and off, which changes
# which tables are printed but nothing that is fitted.
wj_pair <- list(age_6_8 = WJIV_ages_6_8$cormat, age_14_19 = WJIV_ages_14_19$cormat)
wj_pair_N <- c(WJIV_ages_6_8$N, WJIV_ages_14_19$N)
wj_pair_fit <- efa_group(wj_pair, n_factors = 3, N = wj_pair_N, rotation = "varimax")
wj_pair_fit_inv <- efa_group(wj_pair, n_factors = 3, N = wj_pair_N,
rotation = "varimax", invariance = TRUE)
test_that("print and format render the efa_group report", {
local_reproducible_output()
# consensus alignment, orthogonal rotation, invariance verdicts, no bootstrap
mg <- wj_pair_fit_inv
expect_snapshot(print(mg), transform = scrub_group)
# print() is exactly cat(format(x), sep = "\n"), so the two agree line for line
expect_identical(utils::capture.output(print(mg)), format(mg))
# three groups -> one row per pair; identical groups -> perfect congruence
cmat <- test_models$baseline$cormat
mg_ident <- efa_group(list(a = cmat, b = cmat, c = cmat), n_factors = 2, N = 500,
rotation = "varimax", invariance = TRUE)
expect_snapshot(print(mg_ident), transform = scrub_group)
# oblique rotation -> reference-alignment header, no invariance section
mg_ob <- suppressMessages(suppressWarnings(
efa_group(wj_pair, n_factors = 3, N = wj_pair_N, rotation = "promax")))
expect_snapshot(print(mg_ob), transform = scrub_group)
})
test_that("print reports the bootstrap congruence intervals and verdicts", {
skip_on_cran()
local_reproducible_output()
g <- rep(c("g1", "g2"), length.out = nrow(GRiPS_raw))
mg <- suppressMessages(suppressWarnings(
efa_group(GRiPS_raw, groups = g, n_factors = 1, b_boot = 50, seed = 42,
invariance = TRUE)))
# the congruence section notes the CIs, the differences section notes the
# CI-excluding-0 count, and the invariance heading reads off the CI lower bound
expect_snapshot(print(mg), transform = scrub_group)
})
test_that("print points out an 'equal' verdict that rests on scale-invariance", {
local_reproducible_output()
# Tucker's congruence is invariant to a proportional rescaling of a factor's loadings,
# so a group whose loadings are uniformly 0.55x another's is graded "equal" on every
# factor while every cell differs. Build that case exactly: two correlation matrices
# implied by proportional loading patterns.
L <- matrix(c(.8, .8, .8, 0, 0, 0,
0, 0, 0, .8, .8, .8), nrow = 6, ncol = 2,
dimnames = list(paste0("V", 1:6), c("F1", "F2")))
implied <- function(L) {
R <- tcrossprod(L)
diag(R) <- 1
dimnames(R) <- list(rownames(L), rownames(L))
R
}
scaled <- list(g1 = implied(L), g2 = implied(0.55 * L))
mg <- efa_group(scaled, n_factors = 2, N = c(500, 500), rotation = "varimax",
invariance = TRUE, seed = 42)
# the premise: perfect congruence, but substantial cell-wise differences
expect_equal(unname(mg$congruence$matched["g1", "g2", ]), c(1, 1), tolerance = 1e-6)
expect_true(all(mg$invariance$verdict == "equal"))
expect_gt(mg$diffs$mean_abs_diff, 0.1)
expect_snapshot(print(mg), transform = scrub_group)
# The pointer is keyed off the salience threshold, so it stays silent when `delta = 0`
# (which flags every cell by construction) and when the groups genuinely agree.
shows_pointer <- function(x) {
any(grepl("proportional rescaling", format(x), fixed = TRUE))
}
expect_true(shows_pointer(mg))
mg_d0 <- efa_group(scaled, n_factors = 2, N = c(500, 500), rotation = "varimax",
invariance = TRUE, delta = 0, seed = 42)
expect_false(shows_pointer(mg_d0))
mg_bands <- wj_pair_fit_inv
expect_true(all(mg_bands$invariance$verdict == "equal"))
expect_lt(max(mg_bands$diffs$mean_abs_diff), 0.1)
expect_false(shows_pointer(mg_bands))
})
test_that("the report wraps its header and splits its tables to the console width", {
# A narrow console with four groups: the header no longer fits on one line and the
# six-column difference table no longer fits in one block.
local_reproducible_output(width = 60)
withr::local_options(cli.width = 60)
bands <- list(age_6_8 = WJIV_ages_6_8$cormat, age_9_13 = WJIV_ages_9_13$cormat,
age_14_19 = WJIV_ages_14_19$cormat, age_20_39 = WJIV_ages_20_39$cormat)
Ns <- c(WJIV_ages_6_8$N, WJIV_ages_9_13$N, WJIV_ages_14_19$N, WJIV_ages_20_39$N)
mg <- efa_group(bands, n_factors = 3, N = Ns, rotation = "varimax",
invariance = TRUE, seed = 42)
lines <- format(mg)
# nothing overruns the console (the rules are drawn to exactly that width)
expect_lte(max(cli::ansi_nchar(lines, type = "width")), 60L)
expect_snapshot(print(mg), transform = scrub_group)
# the same holds for the report that carries the scale-invariance pointer, which is
# long enough to overrun any console unless it is wrapped
L <- matrix(c(.8, .8, .8, 0, 0, 0,
0, 0, 0, .8, .8, .8), nrow = 6, ncol = 2,
dimnames = list(paste0("V", 1:6), c("F1", "F2")))
R <- tcrossprod(0.55 * L)
diag(R) <- 1
R2 <- tcrossprod(L)
diag(R2) <- 1
dimnames(R) <- dimnames(R2) <- list(rownames(L), rownames(L))
mg_scaled <- efa_group(list(g1 = R2, g2 = R), n_factors = 2, N = c(500, 500),
rotation = "varimax", invariance = TRUE, seed = 42)
expect_lte(max(cli::ansi_nchar(format(mg_scaled), type = "width")), 60L)
})
test_that(".compare_loadings can skip the decimal-agreement scan", {
x <- matrix(c(0.57, 0.20, 0.31, 0.44), 2, 2)
y <- matrix(c(0.57, 0.21, 0.31, 0.44), 2, 2)
full <- .compare_loadings(x, y)
quick <- .compare_loadings(x, y, decimals = FALSE)
# the difference summaries are untouched ...
expect_equal(full[c("diff", "mean_abs_diff", "median_abs_diff",
"min_abs_diff", "max_abs_diff", "g")],
quick[c("diff", "mean_abs_diff", "median_abs_diff",
"min_abs_diff", "max_abs_diff", "g")])
# ... and only the two decimal-place statistics are dropped
expect_equal(full$max_dec, 2)
expect_equal(full$are_equal, 1)
expect_true(is.na(quick$max_dec))
expect_true(is.na(quick$are_equal))
})
test_that("plot methods return ggplot objects", {
mg <- wj_pair_fit
expect_s3_class(plot(mg, type = "congruence"), "ggplot")
expect_s3_class(plot(mg, type = "differences"), "ggplot")
expect_s3_class(plot(mg), "ggplot") # default is "congruence"
expect_error(plot(mg, type = "nope"), class = "efa_bad_choice")
})
test_that("the bootstrap congruence plot builds", {
skip_on_cran()
g <- rep(c("g1", "g2"), length.out = nrow(GRiPS_raw))
mg <- suppressMessages(suppressWarnings(
efa_group(GRiPS_raw, groups = g, n_factors = 2, rotation = "promax",
estimator = "PAF", b_boot = 30, seed = 7)))
# with a bootstrap the congruence plot carries a point-range (CI) layer; smoke only
expect_s3_class(plot(mg, type = "congruence"), "ggplot")
expect_s3_class(plot(mg, type = "differences"), "ggplot")
})
test_that("the deterministic plots match their vdiffr baselines", {
skip_if_not_installed("vdiffr")
mg <- wj_pair_fit
vdiffr::expect_doppelganger("efa_group congruence plot",
plot(mg, type = "congruence"))
vdiffr::expect_doppelganger("efa_group differences plot",
plot(mg, type = "differences"))
})
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.