Nothing
# Tests for comparability() -- split-half factor comparability (M46).
# Shared fixture: one seeded PCA run on sim16, reused across structural,
# discriminating, and print/plot tests (memoised to keep the suite fast).
.cmp_cache <- new.env(parent = emptyenv())
.get_cmp <- function() {
if (is.null(.cmp_cache$cmp)) {
.cmp_cache$cmp <- suppressMessages(
comparability(sim16, k_max = 5, n_splits = 4, seed = 1)
)
}
.cmp_cache$cmp
}
# ---- Object structure --------------------------------------------------------
test_that("comparability() returns a well-formed object", {
cmp <- .get_cmp()
expect_s3_class(cmp, "comparability")
expect_named(
cmp,
c(
"coefficients", "summary", "k_max", "k_requested", "n_splits", "n_half",
"engine", "cor", "fm", "n_obs", "n_vars", "seed", "call"
)
)
expect_equal(cmp$k_max, 5L)
expect_equal(cmp$k_requested, 5L)
expect_equal(cmp$n_splits, 4L)
expect_equal(cmp$n_half, 500L)
expect_equal(cmp$engine, "pca")
expect_equal(cmp$n_obs, 1000L)
expect_equal(cmp$n_vars, 16L)
# One row per split x level x factor; one summary row per level x factor.
n_factors <- sum(seq_len(5L))
expect_equal(nrow(cmp$coefficients), 4L * n_factors)
expect_equal(nrow(cmp$summary), n_factors)
expect_named(cmp$coefficients, c("split", "level", "factor", "r", "phi"))
expect_named(
cmp$summary,
c("level", "factor", "r_median", "r_min", "phi_median", "phi_min", "n_splits_ok")
)
# Coefficients are correlations / congruences: bounded by 1 in magnitude.
expect_true(all(abs(cmp$coefficients$r) <= 1 + 1e-8))
expect_true(all(abs(cmp$coefficients$phi) <= 1 + 1e-8))
expect_true(all(cmp$summary$n_splits_ok == 4L))
# Factor labels match the full-sample ackwards() labelling scheme.
expect_true(all(grepl("^m\\d+f\\d+$", cmp$summary$factor)))
})
# ---- Discriminating behaviour (the acceptance-criterion test) ----------------
test_that("comparability() separates replicable from non-replicable structure", {
cmp <- .get_cmp()
sm <- cmp$summary
# sim16 has a known 1 -> 2 -> 4 hierarchy: those levels replicate cleanly.
expect_true(all(sm$r_median[sm$level %in% c(1L, 2L, 4L)] > 0.95))
expect_true(all(sm$phi_median[sm$level %in% c(1L, 2L, 4L)] > 0.95))
# Level 3 sits between the true 2- and 4-factor structures (an arbitrary
# blend), so at least one of its factors fails to replicate.
expect_lt(min(sm$r_median[sm$level == 3L]), 0.9)
# Level 5 is overextracted by design: the first four factors are the true
# 4-structure (replicate), the fifth is a sample-idiosyncratic fragment.
lev5 <- sm[sm$level == 5L, , drop = FALSE]
expect_true(all(lev5$r_median[lev5$factor != "m5f5"] > 0.95))
expect_lt(lev5$r_median[lev5$factor == "m5f5"], 0.5)
expect_lt(lev5$phi_median[lev5$factor == "m5f5"], 0.5)
})
test_that("comparability() flags a pure-noise factor as non-replicable", {
set.seed(99)
dat <- cbind(
sim16[, 1:8],
noise1 = rnorm(1000), noise2 = rnorm(1000), noise3 = rnorm(1000)
)
cmp <- suppressMessages(comparability(dat, k_max = 3, n_splits = 4, seed = 1))
sm <- cmp$summary
# The two real factors at level 3 replicate; the noise-anchored one does not.
lev3 <- sm[sm$level == 3L, , drop = FALSE]
expect_lt(min(lev3$r_median), 0.8)
expect_equal(sum(lev3$r_median > 0.95), 2L)
})
# ---- Internal algebra: identity, invariance, oracle ---------------------------
test_that(".cross_cor() of a solution with itself has a unit diagonal", {
R <- stats::cor(sim16)
lev <- pca_levels(R, k_max = 3L)$levels[["3"]]
E <- .cross_cor(lev, lev, R)
expect_equal(diag(E), rep(1, 3L), tolerance = 1e-12, ignore_attr = TRUE)
# Self-comparability: every factor matches itself with r = phi = 1.
out <- .level_comparability(lev, lev, lev, R)
expect_equal(out$r, rep(1, 3L), tolerance = 1e-12)
expect_equal(out$phi, rep(1, 3L), tolerance = 1e-12)
})
test_that(".cross_cor() agrees with materialised half-solution scores", {
# Invariant 2 extended to cross-solution correlations: the W'RW algebra on
# the pooled R must match correlating actual scores computed by applying
# both halves' weights to the full (complete-data) sample.
dat <- as.matrix(sim16)
R <- stats::cor(dat)
half_a <- dat[1:500, , drop = FALSE]
half_b <- dat[501:1000, , drop = FALSE]
lev_a <- pca_levels(stats::cor(half_a), k_max = 3L)$levels[["3"]]
lev_b <- pca_levels(stats::cor(half_b), k_max = 3L)$levels[["3"]]
E_algebra <- .cross_cor(lev_a, lev_b, R)
Z <- scale(dat)
E_scores <- stats::cor(Z %*% lev_a$scoring$weights, Z %*% lev_b$scoring$weights)
expect_equal(unname(E_algebra), unname(E_scores), tolerance = 1e-12)
})
test_that(".level_comparability() is invariant to column order and sign", {
dat <- as.matrix(sim16)
R <- stats::cor(dat)
lev_f <- pca_levels(R, k_max = 3L)$levels[["3"]]
lev_a <- pca_levels(stats::cor(dat[1:500, ]), k_max = 3L)$levels[["3"]]
lev_b <- pca_levels(stats::cor(dat[501:1000, ]), k_max = 3L)$levels[["3"]]
base <- .level_comparability(lev_f, lev_a, lev_b, R)
# Permute and sign-flip half B's factors; the anchored coefficients must
# not change (matching + sign alignment absorb both transformations).
perm <- c(3L, 1L, 2L)
flips <- c(-1, 1, -1)
lev_b2 <- lev_b
lev_b2$loadings <- sweep(lev_b$loadings[, perm, drop = FALSE], 2L, flips, "*")
lev_b2$scoring$weights <-
sweep(lev_b$scoring$weights[, perm, drop = FALSE], 2L, flips, "*")
permuted <- .level_comparability(lev_f, lev_a, lev_b2, R)
expect_equal(permuted$r, base$r, tolerance = 1e-12)
expect_equal(permuted$phi, base$phi, tolerance = 1e-12)
})
test_that(".match_square() picks the global-max bijection", {
E <- rbind(
c(0.2, 0.9, 0.1),
c(0.85, 0.3, 0.1),
c(0.4, 0.5, -0.95) # negative: matching is on |r|
)
expect_identical(.match_square(E), c(2L, 1L, 3L))
expect_identical(.match_square(diag(3L)), 1:3)
})
test_that(".level_comparability() returns NA rows for a missing half-level", {
R <- stats::cor(sim16)
lev <- pca_levels(R, k_max = 2L)$levels[["2"]]
out <- .level_comparability(lev, NULL, lev, R)
expect_equal(out$factor, lev$labels)
expect_true(all(is.na(out$r)))
expect_true(all(is.na(out$phi)))
})
# ---- Reproducibility ----------------------------------------------------------
test_that("comparability() is reproducible under a seed", {
a <- suppressMessages(comparability(sim16, k_max = 3, n_splits = 2, seed = 42))
b <- suppressMessages(comparability(sim16, k_max = 3, n_splits = 2, seed = 42))
expect_identical(a$coefficients, b$coefficients)
expect_identical(a$summary, b$summary)
c <- suppressMessages(comparability(sim16, k_max = 3, n_splits = 2, seed = 43))
expect_false(identical(a$coefficients$r, c$coefficients$r))
})
# ---- EFA engine ---------------------------------------------------------------
test_that("comparability() works with the EFA engine", {
cmp <- suppressMessages(
comparability(sim16[, 1:8], k_max = 3, engine = "efa", n_splits = 2, seed = 1)
)
expect_s3_class(cmp, "comparability")
expect_equal(cmp$engine, "efa")
expect_equal(cmp$fm, "minres")
# The true 2-factor structure of these 8 items replicates.
expect_true(all(cmp$summary$r_median[cmp$summary$level == 2L] > 0.95))
})
# ---- Validation ----------------------------------------------------------------
test_that("comparability() rejects unsupported engines and bases with pointers", {
expect_error(
comparability(sim16, k_max = 3, engine = "esem"),
"not supported"
)
expect_error(
comparability(sim16, k_max = 3, cor = "polychoric"),
"not supported"
)
expect_error(comparability(sim16, k_max = 3, engine = "midi"), "must be one of")
expect_error(comparability(sim16, k_max = 3, cor = "kendall"), "must be one of")
})
test_that("comparability() rejects non-raw-data input", {
R <- stats::cor(sim16)
expect_error(comparability(R, k_max = 3), "raw item data")
expect_error(comparability(list(a = 1), k_max = 3), "data frame or numeric matrix")
ch <- matrix(letters[1:20], nrow = 5)
expect_error(comparability(ch, k_max = 3), "numeric columns")
})
test_that("comparability() validates n_splits and half-sample size", {
expect_error(comparability(sim16, k_max = 3, n_splits = 0), "positive integer")
expect_error(comparability(sim16, k_max = 3, n_splits = 1.5), "positive integer")
expect_error(comparability(sim16, k_max = 3, n_splits = c(2, 3)), "positive integer")
# n/2 must exceed p, or half-sample solutions cannot be scored.
expect_error(
comparability(sim16[1:30, ], k_max = 3),
"more rows than variables"
)
})
# ---- Convergence shortfalls (mocked) -------------------------------------------
test_that("a level missing from some half-fits yields NA + a shortfall message", {
real_fit_half <- .fit_half
calls <- 0L
testthat::local_mocked_bindings(
.fit_half = function(data_half, k_max, engine, cor, fm) {
calls <<- calls + 1L
out <- real_fit_half(data_half, k_max, engine, cor, fm)
if (calls == 1L) out[["3"]] <- NULL # split 1, half A: level 3 "fails"
out
},
.package = "ackwards"
)
expect_message(
cmp <- comparability(sim16, k_max = 3, n_splits = 2, seed = 1),
"did not converge"
)
lev3 <- cmp$summary[cmp$summary$level == 3L, , drop = FALSE]
expect_true(all(lev3$n_splits_ok == 1L))
expect_false(anyNA(lev3$r_median)) # one usable split still aggregates
expect_equal(sum(is.na(cmp$coefficients$r)), 3L) # split 1's level-3 rows
expect_no_error(print(cmp)) # "[1/2 splits usable]" suffix branch
})
test_that("a level with zero usable splits aggregates to NA and prints", {
real_fit_half <- .fit_half
testthat::local_mocked_bindings(
.fit_half = function(data_half, k_max, engine, cor, fm) {
out <- real_fit_half(data_half, k_max, engine, cor, fm)
out[["3"]] <- NULL # level 3 "fails" in every half
out
},
.package = "ackwards"
)
expect_message(
cmp <- comparability(sim16, k_max = 3, n_splits = 2, seed = 1),
"did not converge"
)
lev3 <- cmp$summary[cmp$summary$level == 3L, , drop = FALSE]
expect_true(all(is.na(lev3$r_median)))
expect_true(all(lev3$n_splits_ok == 0L))
expect_no_error(print(cmp)) # "no usable splits" branch
})
# ---- print + autoplot -----------------------------------------------------------
test_that("print.comparability() runs and returns invisibly", {
cmp <- .get_cmp()
# cli output is not reliably captured by expect_output (see test-print.R);
# assert the method runs and honours the print contract.
expect_no_error(print(cmp))
expect_invisible(print(cmp))
})
test_that("autoplot.comparability() builds a ggplot", {
skip_if_not_installed("ggplot2")
cmp <- .get_cmp()
p <- autoplot(cmp)
expect_s3_class(p, "ggplot")
# Both panels present: score comparability and loading congruence.
built <- ggplot2::ggplot_build(p)
expect_equal(length(levels(built$plot$data$panel)), 2L)
})
test_that("benchmark citations name the verified lineage, not Goldberg (1990)", {
# Regression test (2026-07-16): the .90/.95 benchmark lines used to cite
# "Goldberg, 1990", which contains no split-half comparability analyses.
# Verified lineage (cairn/references/): Everett 1983 (procedure + .90
# rationale) and Saucier et al. 2005 (the Goldberg-lab .90 split-half gate).
cmp <- .get_cmp()
msgs <- character()
withCallingHandlers(
txt <- utils::capture.output(print(cmp)),
message = function(m) {
msgs <<- c(msgs, conditionMessage(m))
invokeRestart("muffleMessage")
}
)
out <- cli::ansi_strip(paste(c(txt, msgs), collapse = " "))
expect_match(out, "Everett", fixed = TRUE)
expect_match(out, "Saucier", fixed = TRUE)
expect_no_match(out, "Goldberg, 1990", fixed = TRUE)
skip_if_not_installed("ggplot2")
cap <- autoplot(cmp)$labels$caption
expect_match(cap, "Saucier", fixed = TRUE)
expect_no_match(cap, "Goldberg, 1990", fixed = TRUE)
})
# ---- Post-review follow-up coverage ---------------------------------------------
test_that(".level_comparability() returns NA rows when cross matrices carry NA", {
R <- stats::cor(sim16)
lev <- pca_levels(R, k_max = 2L)$levels[["2"]]
# An NA cell in the pooled R (pathological pairwise missingness) propagates
# into the cross-solution matrices; matching must degrade to NA, not crash.
R_bad <- R
R_bad[1L, 2L] <- R_bad[2L, 1L] <- NA_real_
out <- .level_comparability(lev, lev, lev, R_bad)
expect_equal(out$factor, lev$labels)
expect_true(all(is.na(out$r)))
expect_true(all(is.na(out$phi)))
})
test_that(".fit_half() returns an empty levels list when a half cannot be factored", {
d <- as.matrix(sim16[1:100, 1:6])
d[, 6] <- 1 # zero-variance column -> NA row/col in the half R
expect_identical(.fit_half(d, k_max = 3L, engine = "pca", cor = "pearson", fm = "minres"), list())
expect_identical(.fit_half(d, k_max = 3L, engine = "efa", cor = "pearson", fm = "minres"), list())
})
test_that("unknown arguments in ... error loudly (typo guard)", {
expect_error(comparability(sim16, k_max = 3, nsplits = 20), "Unknown argument")
expect_error(suggest_k(sim16, kmax = 4), "Unknown argument")
expect_error(ackwards(sim16, k_max = 3, alignsigns = FALSE), "Unknown argument")
# Unnamed extras are rejected too.
expect_error(comparability(sim16, 3, "pca", "pearson", "minres", 2, NULL, 42), "unnamed extra")
# The M34 moved-args guard keeps its more specific message.
expect_error(ackwards(sim16, k_max = 3, prune = "redundant"), "no longer")
})
test_that("comparability() works with a single split and the spearman basis", {
cmp1 <- suppressMessages(comparability(sim16, k_max = 2, n_splits = 1, seed = 5))
expect_equal(cmp1$n_splits, 1L)
# With one split the median and min across splits coincide.
expect_identical(cmp1$summary$r_median, cmp1$summary$r_min)
cmp_sp <- suppressMessages(
comparability(sim16, k_max = 2, cor = "spearman", n_splits = 2, seed = 5)
)
expect_equal(cmp_sp$cor, "spearman")
expect_true(all(cmp_sp$summary$r_median > 0.9)) # sim16's 2-level structure
})
test_that("a truncated full-sample anchor is recorded and printed", {
real_ackwards <- ackwards
testthat::local_mocked_bindings(
# Simulate the anchor truncating (non-convergence below the request):
# whatever k_max comparability() asks for, the full-sample fit stops at 2.
ackwards = function(data, k_max, ...) real_ackwards(data, k_max = 2L, ...),
.package = "ackwards"
)
cmp <- suppressMessages(comparability(sim16, k_max = 3, n_splits = 2, seed = 1))
expect_equal(cmp$k_max, 2L)
expect_equal(cmp$k_requested, 3L)
expect_true(all(cmp$coefficients$level <= 2L))
expect_no_error(print(cmp)) # "(requested 1-3; ...)" suffix branch
})
test_that("autoplot.comparability() handles all-NA levels from shortfalls", {
skip_if_not_installed("ggplot2")
real_fit_half <- .fit_half
testthat::local_mocked_bindings(
.fit_half = function(data_half, k_max, engine, cor, fm) {
out <- real_fit_half(data_half, k_max, engine, cor, fm)
out[["3"]] <- NULL # level 3 "fails" in every half
out
},
.package = "ackwards"
)
cmp <- suppressMessages(comparability(sim16, k_max = 3, n_splits = 2, seed = 1))
p <- autoplot(cmp)
expect_s3_class(p, "ggplot")
# NA coefficients are dropped from the plot data, not drawn.
expect_false(anyNA(p$data$value))
expect_true(all(p$data$level <= 2L))
})
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.