Nothing
# Tests for boot_edges() -- bootstrap confidence intervals on edges (M47).
# Shared fixture: one seeded PCA bootstrap on sim16, reused across the
# structure / seed / integration tests (memoised to keep the suite fast).
.boot_cache <- new.env(parent = emptyenv())
.get_boot <- function() {
if (is.null(.boot_cache$x)) {
x <- suppressMessages(suppressWarnings(ackwards(sim16, k_max = 3)))
.boot_cache$x <- x
.boot_cache$xb <- suppressMessages(
boot_edges(x, sim16, n_boot = 200, seed = 1)
)
}
.boot_cache$xb
}
# ---- Object structure --------------------------------------------------------
test_that("boot_edges() returns the object with a well-formed $boot", {
xb <- .get_boot()
expect_s3_class(xb, "ackwards")
expect_type(xb$boot, "list")
expect_named(xb$boot, c("edges", "n_boot", "conf", "seed"))
be <- xb$boot$edges
expect_s3_class(be, "data.frame")
expect_named(be, c(
"from", "to", "level_from", "level_to", "r", "se", "lo", "hi", "n_boot_ok"
))
# One row per edge, aligned with the object's tidy edge table.
expect_identical(nrow(be), nrow(xb$edges$tidy))
expect_identical(be$from, xb$edges$tidy$from)
expect_identical(be$to, xb$edges$tidy$to)
expect_identical(be$r, xb$edges$tidy$r)
})
test_that("boot_edges() intervals bracket the point estimate and endpoints order", {
be <- .get_boot()$boot$edges
expect_true(all(be$lo <= be$hi))
# Percentile endpoints stay inside the correlation bound.
expect_true(all(be$lo >= -1 & be$hi <= 1))
expect_true(all(be$se >= 0))
# The point estimate lies within the (wider) interval for stable edges;
# allow a small slack because percentile endpoints are order statistics.
expect_true(all(be$r >= be$lo - 0.05 & be$r <= be$hi + 0.05))
})
test_that("boot_edges() is pipeable off ackwards()", {
xb <- suppressMessages(suppressWarnings(
ackwards(sim16, k_max = 3) |> boot_edges(sim16, n_boot = 30, seed = 4)
))
expect_false(is.null(xb$boot))
})
# ---- Reproducibility + serial == parallel ------------------------------------
test_that("boot_edges() is reproducible under a seed", {
x <- suppressMessages(suppressWarnings(ackwards(sim16, k_max = 3)))
a <- suppressMessages(boot_edges(x, sim16, n_boot = 80, seed = 11))
b <- suppressMessages(boot_edges(x, sim16, n_boot = 80, seed = 11))
expect_identical(a$boot$edges, b$boot$edges)
# A different seed gives a different (but same-shaped) result.
c <- suppressMessages(boot_edges(x, sim16, n_boot = 80, seed = 12))
expect_false(isTRUE(all.equal(a$boot$edges$se, c$boot$edges$se)))
})
test_that("serial and parallel dispatch agree exactly (upfront indices)", {
skip_if_not_installed("future.apply")
skip_if_not_installed("future")
x <- suppressMessages(suppressWarnings(ackwards(sim16, k_max = 3)))
skip_if(!future::supportsMulticore(), "multicore plan unavailable here")
future::plan(future::sequential)
serial <- suppressMessages(boot_edges(x, sim16, n_boot = 60, seed = 21))
future::plan(future::multicore, workers = 2)
on.exit(future::plan(future::sequential), add = TRUE)
parallel <- suppressMessages(boot_edges(x, sim16, n_boot = 60, seed = 21))
expect_equal(serial$boot$edges, parallel$boot$edges)
})
# ---- Anchoring (label switching + sign flipping) -----------------------------
test_that(".anchor_levels() restores full-sample factor order and sign", {
x <- suppressMessages(suppressWarnings(ackwards(sim16, k_max = 3)))
lev <- x$levels
# Build a "replicate" that is the full-sample solution with, at each level,
# columns permuted and some signs flipped -- exactly the label-switching /
# sign-flipping an unanchored replicate would exhibit. Anchoring must undo it.
rep_levels <- lapply(lev, function(l) {
k <- l$k
perm <- rev(seq_len(k))
signs <- rep(c(1, -1), length.out = k)
l$loadings <- sweep(l$loadings[, perm, drop = FALSE], 2L, signs, "*")
l$scoring$weights <- sweep(l$scoring$weights[, perm, drop = FALSE], 2L, signs, "*")
l$scoring$score_var <- l$scoring$score_var[perm]
l
})
anchored <- ackwards:::.anchor_levels(lev, rep_levels, x$r)
expect_false(any(vapply(anchored, is.null, logical(1L))))
# After anchoring, each replicate factor aligns (positively) with its
# full-sample counterpart, so the cross-solution matrix is ~ identity.
for (ki in names(lev)) {
cc <- ackwards:::.cross_cor(lev[[ki]], anchored[[ki]], x$r)
expect_equal(cc, diag(nrow(cc)), tolerance = 1e-8, ignore_attr = TRUE)
}
# And edges recomputed from the anchored replicate reproduce the object's
# own edges (the permutation/sign scrambling is fully undone).
E_anchored <- compute_edges(
levels = anchored, R = x$r, edge_method = "algebra",
pairs = "adjacent"
)$matrices
for (key in names(x$edges$matrices)) {
expect_equal(E_anchored[[key]], x$edges$matrices[[key]],
tolerance = 1e-8, ignore_attr = TRUE
)
}
})
test_that("bootstrap primary-parent edges center on the full-sample estimate", {
xb <- .get_boot()
be <- xb$boot$edges
prim <- xb$edges$tidy$is_primary
# Primary edges are the stable spine; their point estimate should sit inside
# the bootstrap interval (no sign-flip/label-switch corruption pulling the
# distribution off the estimate).
dprim <- be[prim, ]
expect_true(all(dprim$r >= dprim$lo - 1e-6 & dprim$r <= dprim$hi + 1e-6))
})
# ---- Statistical oracle ------------------------------------------------------
test_that("CI arithmetic matches Fisher-z on fixed weights; full pipeline adds refit variance", {
# The full boot_edges pipeline re-extracts factors each replicate, so its SE
# legitimately EXCEEDS the fixed-weights (Fisher-z) SE that treats the scores
# as observed variables. This test asserts both halves of that relationship:
# (a) a fixed-weights percentile bootstrap of the materialised scores
# reproduces the Fisher-z analytic CI (validates the percentile + SE
# arithmetic), and
# (b) the full pipeline centers on the estimate and its SE is >= the
# fixed-weights SE and of the same order (validates the machinery and
# the added refit variance).
x <- suppressMessages(suppressWarnings(ackwards(sim16, k_max = 2)))
sc <- augment(x, data = sim16, append = FALSE)
s1 <- sc$.m1f1
s2 <- sc$.m2f1
n <- length(s1)
r0 <- cor(s1, s2)
z <- atanh(r0)
se_z <- 1 / sqrt(n - 3)
fisher_lo <- tanh(z - stats::qnorm(0.975) * se_z)
fisher_hi <- tanh(z + stats::qnorm(0.975) * se_z)
fisher_se_r <- se_z * (1 - r0^2) # delta-method r-space SE
# (a) fixed-weights percentile bootstrap ~ Fisher-z
set.seed(2024)
rb <- replicate(3000, {
i <- sample.int(n, n, replace = TRUE)
cor(s1[i], s2[i])
})
expect_equal(quantile(rb, 0.025, names = FALSE), fisher_lo, tolerance = 0.02)
expect_equal(quantile(rb, 0.975, names = FALSE), fisher_hi, tolerance = 0.02)
expect_equal(sd(rb), fisher_se_r, tolerance = 0.1) # Monte Carlo slack
# (b) full pipeline: centered on r0, SE >= fixed-weights SE, same order
xb <- suppressMessages(boot_edges(x, sim16, n_boot = 400, seed = 7))
row <- xb$boot$edges[
xb$boot$edges$from == "m1f1" & xb$boot$edges$to == "m2f1",
]
expect_true(row$r >= row$lo && row$r <= row$hi)
expect_gt(row$se, fisher_se_r) # refit variance strictly adds
expect_lt(row$se, 4 * fisher_se_r) # but stays the same order of magnitude
})
test_that("strong edges exclude 0, a near-zero edge covers 0 (discriminating)", {
xb <- .get_boot()
be <- xb$boot$edges
prim <- xb$edges$tidy$is_primary
# Every primary-parent edge on sim16 is strong; its 95% CI excludes 0.
dprim <- be[prim, ]
expect_true(all(dprim$lo > 0))
# There is at least one near-zero off-lineage edge whose CI covers 0.
weakest <- be[which.min(abs(be$r)), ]
expect_lt(abs(weakest$r), 0.1)
expect_true(weakest$lo < 0 && weakest$hi > 0)
})
# ---- Invariant 7: a failing replicate is dropped, not fatal ------------------
test_that("a failing replicate is dropped and counted; the run completes", {
x <- suppressMessages(suppressWarnings(ackwards(sim16, k_max = 3)))
# Rig ~1/4 of the replicate refits to fail. .boot_replicate wraps the engine
# in tryCatch, so a failure must produce an all-NA replicate (dropped), never
# an abort (Invariant 7). Deterministic given the counter.
real_pca <- ackwards:::pca_levels
call_i <- 0L
fail_pca <- function(R, k_max, cor = "pearson", keep_fits = FALSE) {
call_i <<- call_i + 1L
if (call_i %% 4L == 0L) stop("rigged replicate failure")
real_pca(R, k_max = k_max, cor = cor, keep_fits = keep_fits)
}
testthat::local_mocked_bindings(pca_levels = fail_pca)
xb <- suppressMessages(boot_edges(x, sim16, n_boot = 40, seed = 5))
expect_s3_class(xb, "ackwards")
be <- xb$boot$edges
# Some replicates were dropped, so usable counts fall below n_boot but stay
# high enough to form intervals.
expect_true(all(be$n_boot_ok < 40))
expect_true(all(be$n_boot_ok > 1))
expect_false(anyNA(be$lo))
expect_false(anyNA(be$hi))
})
# ---- Guards ------------------------------------------------------------------
test_that("boot_edges() rejects correlation-matrix-input objects", {
R <- cor(sim16)
x <- suppressMessages(suppressWarnings(ackwards(R, k_max = 3, n_obs = 1000)))
expect_error(boot_edges(x, sim16, n_boot = 10), "raw item data")
})
test_that("boot_edges() rejects ESEM and polychoric objects", {
skip_if_not_installed("lavaan")
ord <- .make_ordinal_data()
xe <- suppressMessages(suppressWarnings(
ackwards(ord, k_max = 2, engine = "esem")
))
expect_error(boot_edges(xe, ord, n_boot = 10), "esem")
xp <- suppressMessages(suppressWarnings(
ackwards(ord, k_max = 2, engine = "efa", cor = "polychoric")
))
expect_error(boot_edges(xp, ord, n_boot = 10), "olychoric")
})
test_that("boot_edges() validates data and its arguments", {
x <- suppressMessages(suppressWarnings(ackwards(sim16, k_max = 3)))
expect_error(boot_edges(x, n_boot = 10), "required")
expect_error(boot_edges(x, sim16[, 1:5], n_boot = 10), "missing")
expect_error(boot_edges(x, sim16, n_boot = 1), "n_boot")
expect_error(boot_edges(x, sim16, n_boot = 10, conf = 1.5), "conf")
expect_error(
boot_edges(x, sim16, n_boot = 10, notanarg = TRUE),
"unused|unknown|notanarg"
)
})
test_that("boot_edges() warns when data do not look like the fit data", {
x <- suppressMessages(suppressWarnings(ackwards(sim16, k_max = 3)))
# A different sample of the columns: right variables, wrong rows/moments.
wrong <- sim16
wrong[] <- lapply(wrong, function(v) v + 5)
expect_warning(
suppressMessages(boot_edges(x, wrong, n_boot = 10, seed = 1)),
"does not look like"
)
})
test_that("boot_edges() works with the EFA engine", {
x <- suppressMessages(suppressWarnings(
ackwards(sim16, k_max = 3, engine = "efa")
))
xb <- suppressMessages(suppressWarnings(
boot_edges(x, sim16, n_boot = 30, seed = 9)
))
expect_false(is.null(xb$boot))
expect_true(all(xb$boot$edges$lo <= xb$boot$edges$hi))
})
# ---- Downstream integration --------------------------------------------------
test_that("tidy(what = 'edges') gains bootstrap columns after boot_edges()", {
xb <- .get_boot()
te <- tidy(xb)
expect_true(all(c("se", "lo", "hi", "n_boot_ok") %in% names(te)))
# A plain object's edge table is unchanged.
expect_false(any(c("se", "lo", "hi") %in% names(tidy(.boot_cache$x))))
# sort = "strength" preserves the joined CI columns.
ts <- tidy(xb, sort = "strength")
expect_true(all(c("se", "lo", "hi") %in% names(ts)))
expect_identical(nrow(ts), nrow(te))
})
test_that("print() and summary() note the bootstrap coverage", {
# cli output is not reliably captured by expect_output (see test-print.R);
# capture the cli stream directly and assert the boot line is present.
xb <- .get_boot()
print_out <- cli::cli_fmt(print(xb))
expect_true(any(grepl("bootstrap", print_out, ignore.case = TRUE)))
summ_out <- cli::cli_fmt(print(summary(xb)))
expect_true(any(grepl("bootstrap", summ_out, ignore.case = TRUE)))
})
# ---- Remaining validation + missing-data branches ----------------------------
test_that("boot_edges() rejects non-data-frame/matrix and non-numeric data", {
x <- suppressMessages(suppressWarnings(ackwards(sim16, k_max = 3)))
expect_error(boot_edges(x, data = 1:5, n_boot = 10), "data frame or numeric matrix")
expect_error(
boot_edges(x, data = data.frame(a = "x", b = "y"), n_boot = 10),
"only numeric columns"
)
})
test_that("boot_edges() reports a column-count mismatch for an unnamed matrix", {
x <- suppressMessages(suppressWarnings(ackwards(sim16, k_max = 3)))
wrong <- unname(as.matrix(sim16))[, 1:10]
expect_error(boot_edges(x, wrong, n_boot = 10), "10 columns but the model")
})
test_that("boot_edges() handles listwise and FIML fits", {
d <- as.matrix(sim16)
d[1:20, 1] <- NA # induce missingness
x_lw <- suppressMessages(suppressWarnings(
ackwards(d, k_max = 3, missing = "listwise")
))
xb_lw <- suppressMessages(suppressWarnings(
boot_edges(x_lw, d, n_boot = 20, seed = 2)
))
expect_false(is.null(xb_lw$boot))
x_fiml <- suppressMessages(suppressWarnings(
ackwards(d, k_max = 3, missing = "fiml")
))
# The FIML cost note fires; each replicate re-estimates via corFiml.
xb_fiml <- suppressMessages(suppressWarnings(
boot_edges(x_fiml, d, n_boot = 15, seed = 2)
))
expect_false(is.null(xb_fiml$boot))
expect_true(all(xb_fiml$boot$edges$lo <= xb_fiml$boot$edges$hi))
})
# ---- Internal dispatch + anchoring edge branches -----------------------------
test_that(".boot_lapply falls back to serial lapply when future.apply is absent", {
testthat::local_mocked_bindings(
is_installed = function(...) FALSE,
.package = "rlang"
)
out <- ackwards:::.boot_lapply(1:3, function(x) x^2)
expect_identical(out, list(1, 4, 9))
})
test_that(".anchor_levels() skips missing levels and NA cross-correlations", {
x <- suppressMessages(suppressWarnings(ackwards(sim16, k_max = 3)))
# A replicate missing its middle level: that level anchors to NULL.
lr <- x$levels
lr["2"] <- list(NULL)
a <- ackwards:::.anchor_levels(x$levels, lr, x$r)
expect_null(a[["2"]])
expect_false(is.null(a[["1"]]))
# An NA in the pooled R makes every cross-correlation NA -> all levels skip.
Rna <- x$r
Rna[1, 2] <- NA
a2 <- ackwards:::.anchor_levels(x$levels, x$levels, Rna)
expect_true(all(vapply(a2, is.null, logical(1L))))
})
test_that(".boot_replicate returns all-NA when fewer than two levels anchor", {
x <- suppressMessages(suppressWarnings(ackwards(sim16, k_max = 3)))
keys <- names(x$edges$matrices)
dims <- lapply(x$edges$matrices, dim)
dm <- as.matrix(sim16)
n_edges <- sum(vapply(dims, prod, numeric(1L)))
# An all-NA anchor matrix: refit succeeds, but no level can anchor.
Rna <- matrix(NA_real_, ncol(dm), ncol(dm))
res <- ackwards:::.boot_replicate(
seq_len(nrow(dm)), dm, x$levels, Rna, keys, dims,
engine = "pca", cor = "pearson", fm = "minres",
missing_eff = "pairwise", k_max = 3, pairs = "adjacent"
)
expect_length(res, n_edges)
expect_true(all(is.na(res)))
})
test_that(".boot_replicate NA-fills edges whose level did not anchor", {
x <- suppressMessages(suppressWarnings(ackwards(sim16, k_max = 3)))
keys <- names(x$edges$matrices)
dims <- lapply(x$edges$matrices, dim)
dm <- as.matrix(sim16)
# Anchor only levels 1 and 3 (drop 2): the object's 1:2 and 2:3 keys are then
# absent from the replicate edges and must be NA-filled.
real_anchor <- ackwards:::.anchor_levels
drop_mid <- function(x_levels, levels_rep, R_full) {
a <- real_anchor(x_levels, levels_rep, R_full)
a["2"] <- list(NULL)
a
}
testthat::local_mocked_bindings(.anchor_levels = drop_mid)
res <- ackwards:::.boot_replicate(
seq_len(nrow(dm)), dm, x$levels, x$r, keys, dims,
engine = "pca", cor = "pearson", fm = "minres",
missing_eff = "pairwise", k_max = 3, pairs = "adjacent"
)
# Edges incident on level 2 (the 1:2 and 2:3 blocks) are NA.
expect_true(anyNA(res))
})
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.