tests/testthat/test-prune.R

test_that("ackwards() no longer accepts pruning args (clean move, M34)", {
  skip_if_not_installed("psych")
  data <- as.data.frame(matrix(rnorm(300), 100, 6))
  expect_error(
    suppressWarnings(ackwards(data, k_max = 3, prune = "redundant")),
    "no longer"
  )
  expect_error(
    suppressWarnings(ackwards(data, k_max = 3, redundancy_r = 0.9)),
    "no longer"
  )
})

test_that("rules='none' (default) leaves x$prune as NULL and does not touch pairs/edges", {
  skip_if_not_installed("psych")
  set.seed(10)
  data <- as.data.frame(matrix(rnorm(300), 100, 6))
  x <- cached(ackwards(data, k_max = 3))
  expect_null(x$prune)
  expect_equal(x$meta$pairs, "adjacent")

  x2 <- prune(x)
  expect_null(x2$prune)
  expect_equal(x2$meta$pairs, "adjacent")
})

test_that("prune(x, 'redundant') does not upgrade x$meta$pairs or mutate x$edges", {
  # M34: edges needed for redundancy chains are recomputed fresh inside
  # prune() itself; the fit-time object's pairs/edges are left untouched
  # (single edge path, Invariant 1).
  skip_if_not_installed("psych")
  set.seed(10)
  data <- as.data.frame(matrix(rnorm(300), 100, 6))

  x <- cached(ackwards(data, k_max = 3))
  xp <- suppressMessages(prune(x, "redundant"))

  expect_equal(xp$meta$pairs, "adjacent")
  expect_identical(xp$edges, x$edges)
  expect_false(is.null(xp$prune))
})

test_that("prune='redundant' detects a known redundant chain and flags correctly", {
  skip_if_not_installed("psych")

  # Construct data with a clear 2-factor structure: one general + two specifics.
  # At high k, expect near-identical components to appear across consecutive levels.
  set.seed(42)
  n <- 500
  g <- rnorm(n)
  s1 <- rnorm(n)
  s2 <- rnorm(n)
  data <- data.frame(
    x1 = 0.9 * g + 0.3 * s1 + rnorm(n, sd = 0.1),
    x2 = 0.9 * g + 0.3 * s1 + rnorm(n, sd = 0.1),
    x3 = 0.9 * g + 0.3 * s1 + rnorm(n, sd = 0.1),
    x4 = 0.9 * g + 0.3 * s2 + rnorm(n, sd = 0.1),
    x5 = 0.9 * g + 0.3 * s2 + rnorm(n, sd = 0.1),
    x6 = 0.9 * g + 0.3 * s2 + rnorm(n, sd = 0.1)
  )

  x <- suppressWarnings(suppressMessages(
    ackwards(data, k_max = 4) |> prune("redundant", redundancy_r = 0.9)
  ))

  nodes <- x$prune$nodes
  expect_s3_class(nodes, "data.frame")
  expect_true(all(c("id", "level", "pruned", "prune_reason") %in% names(nodes)))

  # Number of rows equals total factors across all levels (1+2+3+4 = 10)
  expect_equal(nrow(nodes), sum(seq_len(x$k_max)))
})

test_that("x$prune$nodes has one row per factor with correct structure", {
  skip_if_not_installed("psych")
  set.seed(1)
  data <- as.data.frame(matrix(rnorm(900), 150, 6))
  x <- suppressWarnings(suppressMessages(
    ackwards(data, k_max = 3) |> prune("redundant")
  ))

  nodes <- x$prune$nodes
  expect_equal(nrow(nodes), 1 + 2 + 3) # k=1,2,3
  expect_true(is.logical(nodes$pruned))
  expect_true(is.character(nodes$prune_reason))

  # All non-pruned nodes have NA prune_reason
  expect_true(all(is.na(nodes$prune_reason[!nodes$pruned])))

  # Any pruned nodes have prune_reason "redundant"
  if (any(nodes$pruned)) {
    expect_true(all(nodes$prune_reason[nodes$pruned] == "redundant"))
  }
})

test_that("x$prune$chains has correct structure when chains are found", {
  skip_if_not_installed("psych")
  set.seed(42)
  n <- 500
  g <- rnorm(n)
  s1 <- rnorm(n)
  s2 <- rnorm(n)
  data <- data.frame(
    x1 = 0.9 * g + 0.2 * s1 + rnorm(n, sd = 0.05),
    x2 = 0.9 * g + 0.2 * s1 + rnorm(n, sd = 0.05),
    x3 = 0.9 * g + 0.2 * s1 + rnorm(n, sd = 0.05),
    x4 = 0.9 * g + 0.2 * s2 + rnorm(n, sd = 0.05),
    x5 = 0.9 * g + 0.2 * s2 + rnorm(n, sd = 0.05),
    x6 = 0.9 * g + 0.2 * s2 + rnorm(n, sd = 0.05)
  )
  x <- suppressWarnings(suppressMessages(
    ackwards(data, k_max = 4) |> prune("redundant", redundancy_r = 0.9)
  ))

  if (!is.null(x$prune$chains)) {
    ch <- x$prune$chains
    expect_true(all(c(
      "chain_id", "id", "level", "r_to_prev", "phi_to_prev",
      "retain", "endpoint_r", "endpoint_r_agrees"
    ) %in% names(ch)))
    # Exactly one retained node per chain
    for (cid in unique(ch$chain_id)) {
      expect_equal(sum(ch$retain[ch$chain_id == cid]), 1L)
    }
    # r_to_prev is NA for the first member of each chain (no predecessor)
    first_members <- tapply(seq_len(nrow(ch)), ch$chain_id, min)
    expect_true(all(is.na(ch$r_to_prev[first_members])))
  }
})

test_that("retention rule: chain reaching k_max retains bottom node", {
  skip_if_not_installed("psych")
  set.seed(42)
  n <- 500
  g <- rnorm(n)
  s1 <- rnorm(n)
  s2 <- rnorm(n)
  data <- data.frame(
    x1 = 0.9 * g + 0.2 * s1 + rnorm(n, sd = 0.05),
    x2 = 0.9 * g + 0.2 * s1 + rnorm(n, sd = 0.05),
    x3 = 0.9 * g + 0.2 * s1 + rnorm(n, sd = 0.05),
    x4 = 0.9 * g + 0.2 * s2 + rnorm(n, sd = 0.05),
    x5 = 0.9 * g + 0.2 * s2 + rnorm(n, sd = 0.05),
    x6 = 0.9 * g + 0.2 * s2 + rnorm(n, sd = 0.05)
  )
  k_max <- 4L
  x <- suppressWarnings(suppressMessages(
    ackwards(data, k_max = k_max) |> prune("redundant", redundancy_r = 0.9)
  ))

  if (!is.null(x$prune$chains)) {
    ch <- x$prune$chains
    # For chains that reach k_max, the retained node must be at level k_max
    bottom_chains <- unique(ch$chain_id[ch$level == k_max])
    for (cid in bottom_chains) {
      sub <- ch[ch$chain_id == cid, ]
      retained_level <- sub$level[sub$retain]
      expect_equal(retained_level, k_max)
    }
    # For chains that don't reach k_max, the retained node must be at the top
    mid_chains <- setdiff(unique(ch$chain_id), bottom_chains)
    for (cid in mid_chains) {
      sub <- ch[ch$chain_id == cid, ]
      retained_level <- sub$level[sub$retain]
      expect_equal(retained_level, min(sub$level))
    }
  }
})

test_that("Tucker's phi is computed correctly by .tucker_phi", {
  a <- c(0.8, 0.6, 0.0)
  b <- c(0.6, 0.8, 0.0)
  phi <- ackwards:::.tucker_phi(a, b)
  expected <- sum(a * b) / sqrt(sum(a^2) * sum(b^2))
  expect_equal(phi, expected)

  # Identical vectors → phi = 1
  expect_equal(ackwards:::.tucker_phi(a, a), 1.0)

  # Orthogonal vectors → phi = 0
  expect_equal(ackwards:::.tucker_phi(c(1, 0), c(0, 1)), 0.0)
})

test_that("prune(x, 'artifact') computes phi table without flagging nodes", {
  skip_if_not_installed("psych")
  set.seed(5)
  data <- as.data.frame(matrix(rnorm(900), 150, 6))
  x <- suppressWarnings(suppressMessages(
    ackwards(data, k_max = 3) |> prune("artifact")
  ))

  expect_false(is.null(x$prune))
  expect_false(is.null(x$prune$phi))
  phi_df <- x$prune$phi
  expect_true(all(c("from", "to", "level_from", "level_to", "phi") %in% names(phi_df)))
  expect_true(all(phi_df$level_from < phi_df$level_to))

  # No nodes should be auto-flagged in artifact-only mode
  expect_true(all(!x$prune$nodes$pruned))
})

test_that("prune(x, 'artefact') is accepted as an alias and canonicalizes to 'artifact'", {
  skip_if_not_installed("psych")
  set.seed(5)
  data <- as.data.frame(matrix(rnorm(900), 150, 6))
  x <- suppressWarnings(suppressMessages(
    ackwards(data, k_max = 3) |> prune("artefact")
  ))
  expect_equal(x$prune$rules, "artifact")
})

test_that("redundancy_phi conjunctive criterion is applied when set", {
  skip_if_not_installed("psych")
  set.seed(42)
  n <- 500
  g <- rnorm(n)
  s1 <- rnorm(n)
  s2 <- rnorm(n)
  data <- data.frame(
    x1 = 0.9 * g + 0.2 * s1 + rnorm(n, sd = 0.05),
    x2 = 0.9 * g + 0.2 * s1 + rnorm(n, sd = 0.05),
    x3 = 0.9 * g + 0.2 * s1 + rnorm(n, sd = 0.05),
    x4 = 0.9 * g + 0.2 * s2 + rnorm(n, sd = 0.05),
    x5 = 0.9 * g + 0.2 * s2 + rnorm(n, sd = 0.05),
    x6 = 0.9 * g + 0.2 * s2 + rnorm(n, sd = 0.05)
  )
  # With phi threshold of 1 (impossible), no chains should survive
  x_strict <- suppressWarnings(suppressMessages(
    ackwards(data, k_max = 4) |>
      prune("redundant", redundancy_r = 0.9, redundancy_phi = 1.0)
  ))
  expect_true(all(!x_strict$prune$nodes$pruned))
  expect_null(x_strict$prune$chains)
})

test_that("invalid redundancy_r and redundancy_phi are rejected", {
  skip_if_not_installed("psych")
  data <- as.data.frame(matrix(rnorm(300), 100, 6))
  x <- cached(ackwards(data, k_max = 3))
  expect_error(
    prune(x, "redundant", redundancy_r = 1.5),
    "redundancy_r"
  )
  expect_error(
    prune(x, "redundant", redundancy_phi = -0.1),
    "redundancy_phi"
  )
})

test_that("prune() rejects a misspelled argument via ... (M58 guard)", {
  # Before M58, prune.ackwards() accepted `...` but never guarded it, so a
  # typo'd argument (e.g. redundancy_R vs redundancy_r) was silently swallowed
  # and the default used -- unlike its sibling boot_edges.ackwards().
  skip_if_not_installed("psych")
  data <- as.data.frame(matrix(rnorm(300), 100, 6))
  x <- cached(ackwards(data, k_max = 3))
  expect_error(prune(x, redundancy_R = 0.8), "[Uu]nknown argument")
})

# --- A3: tidy(what = "nodes") ------------------------------------------------

test_that("tidy(x, what='nodes') returns prune$nodes when pruning was applied", {
  skip_if_not_installed("psych")
  set.seed(42)
  n <- 500L
  g <- rnorm(n)
  s1 <- rnorm(n)
  s2 <- rnorm(n)
  data <- data.frame(
    x1 = 0.9 * g + 0.2 * s1 + rnorm(n, sd = 0.05),
    x2 = 0.9 * g + 0.2 * s1 + rnorm(n, sd = 0.05),
    x3 = 0.9 * g + 0.2 * s1 + rnorm(n, sd = 0.05),
    x4 = 0.9 * g + 0.2 * s2 + rnorm(n, sd = 0.05),
    x5 = 0.9 * g + 0.2 * s2 + rnorm(n, sd = 0.05),
    x6 = 0.9 * g + 0.2 * s2 + rnorm(n, sd = 0.05)
  )
  x <- suppressWarnings(suppressMessages(
    ackwards(data, k_max = 4) |> prune("redundant")
  ))

  nodes <- tidy(x, what = "nodes")
  expect_s3_class(nodes, "data.frame")
  expect_true(all(c("id", "level", "pruned", "prune_reason") %in% names(nodes)))
  expect_equal(nrow(nodes), sum(seq_len(x$k_max)))
  expect_equal(nodes, x$prune$nodes)
})

test_that("tidy(x, what='nodes') returns empty frame with correct columns when no pruning", {
  skip_if_not_installed("psych")
  x <- cached(ackwards(psych::bfi[, 1:25], k_max = 3))
  expect_null(x$prune)

  nodes <- tidy(x, what = "nodes")
  expect_s3_class(nodes, "data.frame")
  expect_equal(nrow(nodes), 0L)
  expect_true(all(c("id", "level", "pruned", "prune_reason") %in% names(nodes)))
})

# --- A2: print() pruning section -----------------------------------------------

test_that("print.ackwards() executes without error when pruning annotations are present", {
  # cli output cannot be reliably captured via capture.output() in non-interactive
  # test sessions — the Pruning section and relabeling caveat are verified visually.
  # This test guards against regressions that would make print() error or not
  # return x invisibly (consistent with the convention in test-print.R).
  skip_if_not_installed("psych")
  set.seed(42)
  n <- 500L
  g <- rnorm(n)
  s1 <- rnorm(n)
  s2 <- rnorm(n)
  data <- data.frame(
    x1 = 0.9 * g + 0.2 * s1 + rnorm(n, sd = 0.05),
    x2 = 0.9 * g + 0.2 * s1 + rnorm(n, sd = 0.05),
    x3 = 0.9 * g + 0.2 * s1 + rnorm(n, sd = 0.05),
    x4 = 0.9 * g + 0.2 * s2 + rnorm(n, sd = 0.05),
    x5 = 0.9 * g + 0.2 * s2 + rnorm(n, sd = 0.05),
    x6 = 0.9 * g + 0.2 * s2 + rnorm(n, sd = 0.05)
  )
  x <- suppressWarnings(suppressMessages(
    ackwards(data, k_max = 4) |> prune("redundant")
  ))
  expect_no_error(print(x))
  expect_invisible(print(x))
  # Pruning slot populated confirms the section has content to show
  expect_false(is.null(x$prune))
  expect_true(any(x$prune$nodes$pruned))
})

test_that("print.ackwards() shows artifact section (phi table) for prune(x, 'artifact')", {
  # Covers print.R lines 70-74: the artifact phi-count section.
  skip_if_not_installed("psych")
  suppressMessages(x <- suppressWarnings(
    ackwards(bfi25[, 1:6], k_max = 3) |> prune("artifact")
  ))
  expect_no_error(print(x))
  expect_invisible(print(x))
  expect_false(is.null(x$prune$phi))
})

test_that("print.ackwards() shows phi threshold note when redundancy_phi is set", {
  # Covers print.R line 61: phi_note when redundancy_phi is non-NULL.
  skip_if_not_installed("psych")
  set.seed(42L)
  n <- 500L
  g <- rnorm(n)
  s1 <- rnorm(n)
  s2 <- rnorm(n)
  data <- data.frame(
    x1 = 0.9 * g + 0.2 * s1 + rnorm(n, sd = 0.05),
    x2 = 0.9 * g + 0.2 * s1 + rnorm(n, sd = 0.05),
    x3 = 0.9 * g + 0.2 * s1 + rnorm(n, sd = 0.05),
    x4 = 0.9 * g + 0.2 * s2 + rnorm(n, sd = 0.05),
    x5 = 0.9 * g + 0.2 * s2 + rnorm(n, sd = 0.05),
    x6 = 0.9 * g + 0.2 * s2 + rnorm(n, sd = 0.05)
  )
  x <- suppressWarnings(suppressMessages(
    ackwards(data, k_max = 4) |> prune("redundant", redundancy_phi = 0.9)
  ))
  expect_false(is.null(x$prune$redundancy_phi))
  expect_no_error(print(x))
  expect_invisible(print(x))
})

# --- B1: sibling redundancy tracing ------------------------------------------

test_that("B1 regression: parent with 2 strong-link children produces 2 chains", {
  # Construct a minimal mock ackwards-like object: k=2, level 1 has 1 factor
  # (m1f1), level 2 has 2 factors (m2f1, m2f2), both with |r| >= 0.9 to m1f1.
  # Old (buggy) code only followed the first child; DFS finds both branches.
  mock_L1 <- matrix(rep(0.9, 6),
    ncol = 1,
    dimnames = list(paste0("x", 1:6), "m1f1")
  )
  mock_L2 <- matrix(
    c(
      0.9, 0.9, 0.9, 0.1, 0.1, 0.1,
      0.1, 0.1, 0.1, 0.9, 0.9, 0.9
    ),
    ncol = 2, dimnames = list(paste0("x", 1:6), c("m2f1", "m2f2"))
  )
  E_1_2 <- matrix(c(0.95, 0.93),
    nrow = 1,
    dimnames = list("m1f1", c("m2f1", "m2f2"))
  )

  mock_x <- list(
    k_max = 2L,
    levels = list(
      "1" = list(labels = "m1f1", loadings = mock_L1),
      "2" = list(labels = c("m2f1", "m2f2"), loadings = mock_L2)
    ),
    lineage = list("1" = NULL, "2" = c(1L, 1L)),
    edges = list(matrices = list("1:2" = E_1_2))
  )

  res <- ackwards:::.find_redundant_chains(mock_x, threshold_r = 0.90, threshold_phi = NULL)

  # Both strong links must produce separate chains
  expect_equal(length(unique(res$chains$chain_id)), 2L)
  expect_true("m2f1" %in% res$chains$id)
  expect_true("m2f2" %in% res$chains$id)

  # m1f1 flagged exactly once (it's the shared root, not retained in either chain)
  expect_equal(sum(res$node_flags$id == "m1f1"), 1L)
  expect_true(res$node_flags$pruned[res$node_flags$id == "m1f1"])

  # Both m2f1 and m2f2 are retained (chain reaches k_max = 2)
  expect_false("m2f1" %in% res$node_flags$id)
  expect_false("m2f2" %in% res$node_flags$id)

  # M53: the adjacent link builder agrees on this shallow (k_max = 2) mock,
  # where the direct and adjacent criteria coincide -- covers the opt-in path.
  res_adj <- ackwards:::.find_redundant_chains(
    mock_x,
    threshold_r = 0.90, threshold_phi = NULL, criterion = "adjacent"
  )
  expect_equal(length(unique(res_adj$chains$chain_id)), 2L)
  expect_true(res_adj$node_flags$pruned[res_adj$node_flags$id == "m1f1"])
})

# --- B3: internal helper coverage (M23) --------------------------------------

test_that(".tucker_phi() returns NA_real_ when one loading vector is all zeros", {
  # denom = sqrt(0 * sum) = 0 -> defensive NA_real_ branch
  expect_identical(ackwards:::.tucker_phi(c(0, 0, 0), c(0.7, 0.8, 0.9)), NA_real_)
})

test_that(".phi_pairs() handles 'adjacent' which_pairs correctly", {
  # .phi_pairs() is only called with 'all' from .apply_pruning(); the 'adjacent'
  # branch at line 39 requires a direct unit test.
  levels_list <- list(
    "1" = list(
      loadings = matrix(c(.8, .8, .8),
        ncol = 1L,
        dimnames = list(paste0("x", 1:3), "m1f1")
      ),
      labels = "m1f1"
    ),
    "2" = list(
      loadings = matrix(c(.7, .8, .9, .8, .7, .6),
        ncol = 2L,
        dimnames = list(paste0("x", 1:3), c("m2f1", "m2f2"))
      ),
      labels = c("m2f1", "m2f2")
    )
  )
  out <- ackwards:::.phi_pairs(levels_list, which_pairs = "adjacent")
  expect_s3_class(out, "data.frame")
  expect_true("phi" %in% names(out))
  # Only adjacent pairs: 1->2, giving 1*2=2 rows
  expect_equal(nrow(out), 2L)
})

test_that(".find_redundant_chains() handles a missing edge matrix gracefully", {
  # Simulates convergence truncation where the 2:3 edge matrix is absent.
  mock_L1 <- matrix(c(.8, .8, .8),
    ncol = 1L,
    dimnames = list(paste0("x", 1:3), "m1f1")
  )
  mock_L2 <- matrix(c(.9, .9, .9, .1, .1, .1),
    ncol = 2L,
    dimnames = list(paste0("x", 1:3), c("m2f1", "m2f2"))
  )
  mock_L3 <- matrix(c(.85, .85, .85, .1, .1, .1, .1, .1, .1),
    ncol = 3L,
    dimnames = list(paste0("x", 1:3), c("m3f1", "m3f2", "m3f3"))
  )
  E_1_2 <- matrix(c(0.95, 0.93),
    nrow = 1L,
    dimnames = list("m1f1", c("m2f1", "m2f2"))
  )
  # "2:3" key is deliberately absent to trigger the NULL-edge-matrix branch
  mock_x <- list(
    k_max = 3L,
    levels = list(
      "1" = list(labels = "m1f1", loadings = mock_L1),
      "2" = list(labels = c("m2f1", "m2f2"), loadings = mock_L2),
      "3" = list(labels = c("m3f1", "m3f2", "m3f3"), loadings = mock_L3)
    ),
    lineage = list("1" = NULL, "2" = c(1L, 1L), "3" = c(1L, 2L, 2L)),
    edges = list(matrices = list("1:2" = E_1_2)) # no "2:3"
  )
  res <- ackwards:::.find_redundant_chains(mock_x, threshold_r = 0.9, threshold_phi = NULL)
  # Should not error; chains only reflect the 1->2 links
  expect_type(res, "list")

  # M53: same graceful handling under the adjacent builder (its own missing-edge
  # branch). The direct builder above hits the absent "2:3" via its skip lookup;
  # the adjacent builder hits it via the adjacent lookup.
  res_adj <- ackwards:::.find_redundant_chains(
    mock_x,
    threshold_r = 0.9, threshold_phi = NULL, criterion = "adjacent"
  )
  expect_type(res_adj, "list")
})

# --- B4: pruning under convergence truncation --------------------------------

test_that("B4: prune works correctly when hierarchy is truncated (k_eff < k_requested)", {
  skip_if_not_installed("lavaan")
  # 6-variable data: lavaan::efa() errors at k >= 4; requesting k=5 → k_eff=3
  d <- .make_esem_data()

  x <- suppressWarnings(suppressMessages(
    ackwards(d, k_max = 5, engine = "esem") |> prune("redundant")
  ))

  expect_equal(x$k_max, 3L)
  expect_false(is.null(x$prune))

  # Retention rule must use k_eff (3), never a non-existent level 4 or 5
  if (!is.null(x$prune$chains)) {
    retained_levels <- x$prune$chains$level[x$prune$chains$retain]
    expect_true(all(retained_levels <= 3L))
  }

  # All node flags reference existing levels
  expect_true(all(x$prune$nodes$level <= 3L))
})

# ---- Tests for structural artifact signals (Wave 2 / M25) --------------------

test_that("prune(x, 'artifact') populates $structural with correct schema", {
  skip_if_not_installed("psych")
  set.seed(5)
  data <- as.data.frame(matrix(rnorm(900), 150, 6))
  x <- suppressWarnings(suppressMessages(
    ackwards(data, k_max = 3) |> prune("artifact")
  ))

  expect_false(is.null(x$prune$structural))
  struct <- x$prune$structural
  expect_s3_class(struct, "data.frame")
  expect_named(struct, c("id", "level", "few_items", "orphan", "split_merge"))
  # One row per (level, factor): 1 + 2 + 3 = 6 rows for k_max = 3
  expect_equal(nrow(struct), sum(seq_len(3L)))
  expect_type(struct$few_items, "logical")
  expect_type(struct$split_merge, "logical")
  # phi table still present and unchanged
  expect_false(is.null(x$prune$phi))
  # No auto-flagging (artifact is flag/report only)
  expect_true(all(!x$prune$nodes$pruned))
})

test_that("prune(x, 'artifact') stores min_items and orphan_r in prune slot", {
  skip_if_not_installed("psych")
  set.seed(5)
  data <- as.data.frame(matrix(rnorm(900), 150, 6))
  x <- suppressWarnings(suppressMessages(
    ackwards(data, k_max = 3) |>
      prune("artifact", min_items = 2L, orphan_r = 0.3)
  ))
  expect_equal(x$prune$min_items, 2L)
  expect_equal(x$prune$orphan_r, 0.3)
})

test_that("prune(x, 'redundant') leaves $structural NULL", {
  skip_if_not_installed("psych")
  set.seed(1)
  data <- as.data.frame(matrix(rnorm(600), 100, 6))
  x <- suppressWarnings(suppressMessages(
    ackwards(data, k_max = 3) |> prune("redundant")
  ))
  expect_null(x$prune$structural)
})

test_that("few_items flags 2-item factor when min_items = 3", {
  skip_if_not_installed("psych")
  # 3 items on factor 1, 2 items on factor 2 -> factor 2 at k=2 has few_items
  set.seed(42)
  n <- 300L
  f1 <- rnorm(n)
  f2 <- rnorm(n)
  data_few <- data.frame(
    x1 = 0.9 * f1 + rnorm(n, sd = 0.2),
    x2 = 0.9 * f1 + rnorm(n, sd = 0.2),
    x3 = 0.9 * f1 + rnorm(n, sd = 0.2),
    x4 = 0.9 * f2 + rnorm(n, sd = 0.2),
    x5 = 0.9 * f2 + rnorm(n, sd = 0.2)
  )
  x <- suppressMessages(
    ackwards(data_few, k_max = 2) |> prune("artifact", min_items = 3L)
  )
  struct <- x$prune$structural
  k2 <- struct[struct$level == 2L, ]
  # Exactly one k=2 factor should have 2 primary items -> few_items = TRUE
  expect_true(any(k2$few_items))
  # k=1 (single factor, all 5 items) must not be flagged
  k1 <- struct[struct$level == 1L, ]
  expect_false(k1$few_items)
})

test_that("orphan flags with very high orphan_r; none with zero threshold", {
  skip_if_not_installed("psych")
  set.seed(7)
  data <- as.data.frame(matrix(rnorm(600), 100, 6))

  # orphan_r = 0.99: nearly impossible to satisfy; all factors should be orphans
  x_high <- suppressWarnings(suppressMessages(
    ackwards(data, k_max = 3) |> prune("artifact", orphan_r = 0.99)
  ))
  expect_true(any(x_high$prune$structural$orphan, na.rm = TRUE))

  # orphan_r = 0: any nonzero |r| clears the threshold; none should be flagged
  x_low <- suppressWarnings(suppressMessages(
    ackwards(data, k_max = 3) |> prune("artifact", orphan_r = 0)
  ))
  expect_false(any(x_low$prune$structural$orphan, na.rm = TRUE))
})

test_that("split_merge is FALSE at level 1 and level 2 (no multi-parent possible)", {
  skip_if_not_installed("psych")
  set.seed(7)
  data <- as.data.frame(matrix(rnorm(600), 100, 6))
  x <- suppressWarnings(suppressMessages(
    ackwards(data, k_max = 3) |> prune("artifact")
  ))
  struct <- x$prune$structural
  # Level 1: no parent level exists -> always FALSE
  expect_false(any(struct$split_merge[struct$level == 1L]))
  # Level 2: all items share one single k=1 parent -> always FALSE
  expect_false(any(struct$split_merge[struct$level == 2L]))
})

test_that("print.ackwards() shows structural signal count for prune(x, 'artifact')", {
  skip_if_not_installed("psych")
  set.seed(5)
  data <- as.data.frame(matrix(rnorm(900), 150, 6))
  x <- suppressWarnings(suppressMessages(
    ackwards(data, k_max = 3) |> prune("artifact")
  ))
  # print must not error; structural count line present
  expect_no_error(print(x))
  expect_invisible(print(x))
})

test_that("summary.ackwards() shows structural signal count for prune(x, 'artifact')", {
  skip_if_not_installed("psych")
  set.seed(5)
  data <- as.data.frame(matrix(rnorm(900), 150, 6))
  x <- suppressWarnings(suppressMessages(
    ackwards(data, k_max = 3) |> prune("artifact")
  ))
  expect_no_error(summary(x))
  expect_no_error(print(summary(x)))
})

# ---- Tests for Wave 3: Tucker's phi auto-default for non-PCA (M25) ----------

test_that("EFA prune='redundant' auto-applies redundancy_phi=0.95 and announces", {
  skip_if_not_installed("psych")
  set.seed(42)
  n <- 500L
  g <- rnorm(n)
  s1 <- rnorm(n)
  s2 <- rnorm(n)
  data <- data.frame(
    x1 = 0.9 * g + 0.2 * s1 + rnorm(n, sd = 0.05),
    x2 = 0.9 * g + 0.2 * s1 + rnorm(n, sd = 0.05),
    x3 = 0.9 * g + 0.2 * s1 + rnorm(n, sd = 0.05),
    x4 = 0.9 * g + 0.2 * s2 + rnorm(n, sd = 0.05),
    x5 = 0.9 * g + 0.2 * s2 + rnorm(n, sd = 0.05),
    x6 = 0.9 * g + 0.2 * s2 + rnorm(n, sd = 0.05)
  )
  # Default redundancy_phi = NULL on EFA should auto-set to 0.95 and announce
  x_fit <- cached(ackwards(data, k_max = 4, engine = "efa"))
  expect_message(
    x <- prune(x_fit, "redundant"),
    "0.95"
  )
  expect_equal(x$prune$redundancy_phi, 0.95)
})

test_that("PCA prune(x, 'redundant') keeps NULL phi (no auto-phi announcement)", {
  skip_if_not_installed("psych")
  set.seed(1)
  data <- as.data.frame(matrix(rnorm(600), 100, 6))
  # PCA: auto-resolve for NULL should stay NULL; the "0.95" announcement must
  # not appear. Capture messages and grep for absence of phi note.
  x_fit <- cached(ackwards(data, k_max = 3, engine = "pca"))
  msgs <- character(0L)
  x <- withCallingHandlers(
    prune(x_fit, "redundant"),
    message = function(m) {
      msgs <<- c(msgs, conditionMessage(m))
      invokeRestart("muffleMessage")
    }
  )
  expect_false(any(grepl("0\\.95", msgs)))
  expect_null(x$prune$redundancy_phi)
})

test_that("explicit redundancy_phi overrides auto on any engine", {
  skip_if_not_installed("psych")
  set.seed(1)
  data <- as.data.frame(matrix(rnorm(600), 100, 6))
  x <- suppressMessages(
    ackwards(data, k_max = 3, engine = "pca") |>
      prune("redundant", redundancy_phi = 0.8)
  )
  expect_equal(x$prune$redundancy_phi, 0.8)
  x2 <- suppressMessages(
    ackwards(data, k_max = 3, engine = "efa") |>
      prune("redundant", redundancy_phi = 0.8)
  )
  expect_equal(x2$prune$redundancy_phi, 0.8)
})

test_that("redundancy_phi = NA opts out on EFA (no phi filter, no announcement)", {
  skip_if_not_installed("psych")
  set.seed(1)
  data <- as.data.frame(matrix(rnorm(600), 100, 6))
  # NA = explicit opt-out: phi announcement must not appear
  x_fit <- cached(ackwards(data, k_max = 3, engine = "efa"))
  msgs <- character(0L)
  x <- withCallingHandlers(
    prune(x_fit, "redundant", redundancy_phi = NA),
    message = function(m) {
      msgs <<- c(msgs, conditionMessage(m))
      invokeRestart("muffleMessage")
    }
  )
  expect_false(any(grepl("0\\.95", msgs)))
  # Internally, NA gets resolved to NULL (no phi filter)
  expect_null(x$prune$redundancy_phi)
})

test_that("invalid redundancy_phi value still errors (not NA or numeric in (0,1])", {
  skip_if_not_installed("psych")
  data <- as.data.frame(matrix(rnorm(300), 100, 6))
  x <- cached(ackwards(data, k_max = 3))
  expect_error(
    prune(x, "redundant", redundancy_phi = 1.5),
    "redundancy_phi"
  )
  expect_error(
    prune(x, "redundant", redundancy_phi = 0),
    "redundancy_phi"
  )
})

# ---- Wave 3: outcome-change at the phi decision point (M25) -----------------

# Mock an ackwards object with a single strong adjacent link whose score-r
# clears redundancy_r (0.95 >= 0.9) but whose loading congruence is moderate
# (phi ~ 0.71 < 0.95). This isolates the exact decision the auto-phi default
# toggles: with no phi filter the link forms a chain and flags a node; with
# phi > 0.95 the link is rejected and nothing is flagged.
.mock_phi_divergent <- function() {
  items <- paste0("i", 1:4)
  L1 <- matrix(c(0.9, 0.9, 0.9, 0.9),
    ncol = 1,
    dimnames = list(items, "m1f1")
  )
  # m2f1 loads only i1,i2 -> phi(L1[,1], L2[,1]) < 0.95, but scores still
  # correlate strongly with m1f1 via the supplied edge matrix.
  L2 <- matrix(
    c(
      0.9, 0.9, 0.0, 0.0, # m2f1
      0.0, 0.0, 0.9, 0.9 # m2f2
    ),
    ncol = 2, dimnames = list(items, c("m2f1", "m2f2"))
  )
  E12 <- matrix(c(0.95, 0.10),
    nrow = 1,
    dimnames = list("m1f1", c("m2f1", "m2f2"))
  )
  list(
    k_max = 2L,
    levels = list(
      "1" = list(loadings = L1, labels = "m1f1"),
      "2" = list(loadings = L2, labels = c("m2f1", "m2f2"))
    ),
    lineage = list("1" = NA_integer_, "2" = c(1L, 1L)),
    edges = list(matrices = list("1:2" = E12))
  )
}

test_that(".tucker_phi confirms the mock link is below the 0.95 auto threshold", {
  m <- .mock_phi_divergent()
  phi <- ackwards:::.tucker_phi(m$levels[["1"]]$loadings[, 1], m$levels[["2"]]$loadings[, 1])
  expect_lt(phi, 0.95)
  # And the score-r clears redundancy_r.
  expect_gte(abs(m$edges$matrices[["1:2"]]["m1f1", "m2f1"]), 0.9)
})

test_that("phi filter changes the flagged outcome at the chain-finding step", {
  m <- .mock_phi_divergent()

  # No phi filter (|r|-only, == PCA default / redundancy_phi = NA): link forms.
  no_phi <- ackwards:::.find_redundant_chains(m, threshold_r = 0.9, threshold_phi = NULL)
  expect_false(is.null(no_phi$node_flags))
  expect_true("m1f1" %in% no_phi$node_flags$id)

  # Auto default phi > 0.95: the moderate-congruence link is rejected -> no chain.
  with_phi <- ackwards:::.find_redundant_chains(m, threshold_r = 0.9, threshold_phi = 0.95)
  expect_null(with_phi$node_flags)
  expect_null(with_phi$chains)

  # M53: the adjacent builder makes the same phi decision on this k_max = 2 mock
  # (covers its phi-filter and no-link branches).
  no_phi_adj <- ackwards:::.find_redundant_chains(
    m,
    threshold_r = 0.9, threshold_phi = NULL, criterion = "adjacent"
  )
  expect_true("m1f1" %in% no_phi_adj$node_flags$id)
  with_phi_adj <- ackwards:::.find_redundant_chains(
    m,
    threshold_r = 0.9, threshold_phi = 0.95, criterion = "adjacent"
  )
  expect_null(with_phi_adj$node_flags)
  # No qualifying links at all (|r| < 0.999) -> NULL, both criteria.
  expect_null(ackwards:::.find_redundant_chains(
    m,
    threshold_r = 0.999, threshold_phi = NULL, criterion = "adjacent"
  )$chains)
})

test_that("auto-phi flagged set is a subset of the |r|-only set (EFA, end to end)", {
  skip_if_not_installed("psych")
  set.seed(42)
  n <- 500L
  g <- rnorm(n)
  s1 <- rnorm(n)
  s2 <- rnorm(n)
  data <- data.frame(
    x1 = 0.9 * g + 0.2 * s1 + rnorm(n, sd = 0.05),
    x2 = 0.9 * g + 0.2 * s1 + rnorm(n, sd = 0.05),
    x3 = 0.9 * g + 0.2 * s1 + rnorm(n, sd = 0.05),
    x4 = 0.9 * g + 0.2 * s2 + rnorm(n, sd = 0.05),
    x5 = 0.9 * g + 0.2 * s2 + rnorm(n, sd = 0.05),
    x6 = 0.9 * g + 0.2 * s2 + rnorm(n, sd = 0.05)
  )

  x_fit <- cached(ackwards(data, k_max = 4, engine = "efa"))
  na_path <- suppressMessages(prune(x_fit, "redundant", redundancy_phi = NA))
  auto <- suppressMessages(prune(x_fit, "redundant"))

  flagged_na <- na_path$prune$nodes$id[na_path$prune$nodes$pruned]
  flagged_auto <- auto$prune$nodes$id[auto$prune$nodes$pruned]

  # Conjunctive phi can only remove links, never add: the auto-default flagged
  # set must be a subset of the |r|-only set (i.e., never less conservative).
  expect_true(all(flagged_auto %in% flagged_na))
  expect_lte(length(flagged_auto), length(flagged_na))
  # Thresholds recorded honestly.
  expect_equal(auto$prune$redundancy_phi, 0.95)
  expect_null(na_path$prune$redundancy_phi)
})

test_that("redundancy_criterion: default 'direct', 'adjacent' opt-in, recorded, validated (M53)", {
  skip_if_not_installed("psych")
  set.seed(7)
  data <- as.data.frame(matrix(rnorm(600), 100, 6))
  x <- cached(ackwards(data, k_max = 3))

  xd <- suppressMessages(prune(x, "redundant"))
  expect_identical(xd$prune$redundancy_criterion, "direct")

  xa <- suppressMessages(prune(x, "redundant", redundancy_criterion = "adjacent"))
  expect_identical(xa$prune$redundancy_criterion, "adjacent")

  # k_max = 3 is shallow enough that the two criteria agree here.
  expect_setequal(
    xd$prune$nodes$id[xd$prune$nodes$pruned],
    xa$prune$nodes$id[xa$prune$nodes$pruned]
  )

  # Recorded even when no auto rule runs, and invalid values are rejected.
  expect_identical(prune(x)$prune$redundancy_criterion, NULL) # cleared
  expect_identical(
    suppressMessages(prune(x, manual = "m2f1"))$prune$redundancy_criterion, "direct"
  )
  expect_error(prune(x, "redundant", redundancy_criterion = "nope"))
})

# Deep (k = 3) mock where the DIRECT criterion's phi conjunction drops a
# *skip-level* link (M53). m3f1 correlates strongly with the general factor m1f1
# directly (edge "1:3" = 0.95 >= redundancy_r) but their loading congruence is
# low (m1f1 spans all four items, m3f1 only two -> phi ~= 0.71 < 0.95). With no
# phi filter the direct chase climbs m3f1 -> m2f1 -> m1f1; with phi > 0.95 the
# skip link to m1f1 is rejected and the chain stops at m2f1. This exercises the
# direct builder's phi break past the adjacent level, which the shallow k = 2
# phi mock cannot reach.
.mock_direct_skip_phi <- function() {
  items <- paste0("i", 1:4)
  L1 <- matrix(rep(0.9, 4), ncol = 1, dimnames = list(items, "m1f1"))
  L2 <- matrix(
    c(0.9, 0.9, 0.0, 0.0, 0.0, 0.0, 0.9, 0.9),
    ncol = 2, dimnames = list(items, c("m2f1", "m2f2"))
  )
  L3 <- matrix(
    c(0.9, 0.9, 0.0, 0.0, 0.0, 0.0, 0.9, 0.0, 0.0, 0.0, 0.0, 0.9),
    ncol = 3, dimnames = list(items, c("m3f1", "m3f2", "m3f3"))
  )
  list(
    k_max = 3L,
    levels = list(
      "1" = list(loadings = L1, labels = "m1f1"),
      "2" = list(loadings = L2, labels = c("m2f1", "m2f2")),
      "3" = list(loadings = L3, labels = c("m3f1", "m3f2", "m3f3"))
    ),
    lineage = list("1" = NA_integer_, "2" = c(1L, 1L), "3" = c(1L, 2L, 2L)),
    edges = list(matrices = list(
      "1:2" = matrix(c(0.95, 0.10),
        nrow = 1, dimnames = list("m1f1", c("m2f1", "m2f2"))
      ),
      "2:3" = matrix(c(0.97, 0.05, 0.05, 0.05, 0.05, 0.97),
        nrow = 2, dimnames = list(c("m2f1", "m2f2"), c("m3f1", "m3f2", "m3f3"))
      ),
      "1:3" = matrix(c(0.95, 0.10, 0.10),
        nrow = 1, dimnames = list("m1f1", c("m3f1", "m3f2", "m3f3"))
      )
    ))
  )
}

test_that("direct criterion's phi conjunction drops a skip-level link (M53)", {
  m <- .mock_direct_skip_phi()

  # Confirm the setup: strong direct skip r but sub-0.95 congruence to m1f1.
  expect_gte(abs(m$edges$matrices[["1:3"]]["m1f1", "m3f1"]), 0.9)
  expect_lt(
    ackwards:::.tucker_phi(m$levels[["1"]]$loadings[, 1], m$levels[["3"]]$loadings[, 1]),
    0.95
  )

  # No phi filter: the direct chase climbs the skip link to the general factor.
  no_phi <- ackwards:::.find_redundant_chains(m, threshold_r = 0.9, threshold_phi = NULL)
  expect_true("m1f1" %in% no_phi$node_flags$id)

  # phi > 0.95: the skip link to m1f1 is rejected; the chain stops at m2f1, so
  # m1f1 is no longer flagged but the m2f1 -> m3f1 redundancy still is.
  with_phi <- ackwards:::.find_redundant_chains(m, threshold_r = 0.9, threshold_phi = 0.95)
  expect_false("m1f1" %in% with_phi$node_flags$id)
  expect_true("m2f1" %in% with_phi$node_flags$id)
})

# ---- Wave 2: split_merge = TRUE positive path (M25) -------------------------

# Direct unit test of .compute_structural_signals: a level-3 factor whose
# primary items came from two different level-2 primary parents (an items-merge
# anomaly; Forbes Fig 2). Real FA cannot reproduce this deterministically, so
# we mock the loading structure that defines the signal.
.mock_split_merge <- function() {
  items <- paste0("i", 1:6)
  hi <- 0.8
  lo <- 0.1
  L1 <- matrix(rep(hi, 6), ncol = 1, dimnames = list(items, "m1f1"))
  # Level 2: i1-i3 -> m2f1, i4-i6 -> m2f2
  L2 <- matrix(
    c(
      hi, hi, hi, lo, lo, lo, # m2f1
      lo, lo, lo, hi, hi, hi # m2f2
    ),
    ncol = 2, dimnames = list(items, c("m2f1", "m2f2"))
  )
  # Level 3: m3f1 <- {i1 (from m2f1), i4 (from m2f2)} -> split_merge
  #          m3f2 <- {i2, i3} (both m2f1); m3f3 <- {i5, i6} (both m2f2)
  L3 <- matrix(
    c(
      hi, lo, lo, hi, lo, lo, # m3f1 dominant on i1, i4
      lo, hi, hi, lo, lo, lo, # m3f2 dominant on i2, i3
      lo, lo, lo, lo, hi, hi # m3f3 dominant on i5, i6
    ),
    ncol = 3, dimnames = list(items, c("m3f1", "m3f2", "m3f3"))
  )
  # Edges so orphan does not fire (all neighbours well-connected).
  E12 <- matrix(0.7,
    nrow = 1, ncol = 2,
    dimnames = list("m1f1", c("m2f1", "m2f2"))
  )
  E23 <- matrix(0.7,
    nrow = 2, ncol = 3,
    dimnames = list(c("m2f1", "m2f2"), c("m3f1", "m3f2", "m3f3"))
  )
  list(
    levels = list(
      "1" = list(loadings = L1),
      "2" = list(loadings = L2),
      "3" = list(loadings = L3)
    ),
    edges = list(matrices = list("1:2" = E12, "2:3" = E23))
  )
}

test_that("split_merge = TRUE for a factor whose items merge from two parents", {
  m <- .mock_split_merge()
  # min_items = 2 so the 2-item level-3 factors are not also few_items-flagged,
  # isolating the split_merge signal.
  struct <- ackwards:::.compute_structural_signals(m, min_items = 2L, orphan_r = 0.5)

  m3f1 <- struct[struct$id == "m3f1" & struct$level == 3L, ]
  expect_true(m3f1$split_merge)

  # The single-parent level-3 factors are not flagged.
  expect_false(struct$split_merge[struct$id == "m3f2" & struct$level == 3L])
  expect_false(struct$split_merge[struct$id == "m3f3" & struct$level == 3L])

  # Orphan should be FALSE everywhere (edges all 0.7 >= 0.5).
  expect_false(any(struct$orphan, na.rm = TRUE))
})

# ---- Wave 2: min_items / orphan_r input validation (M25) --------------------

test_that("invalid min_items and orphan_r are rejected", {
  skip_if_not_installed("psych")
  data <- as.data.frame(matrix(rnorm(300), 100, 6))
  x <- cached(ackwards(data, k_max = 3))
  expect_error(
    prune(x, "artifact", min_items = 0),
    "min_items"
  )
  expect_error(
    prune(x, "artifact", min_items = 2.5),
    "min_items"
  )
  expect_error(
    prune(x, "artifact", orphan_r = 1.5),
    "orphan_r"
  )
  expect_error(
    prune(x, "artifact", orphan_r = -0.1),
    "orphan_r"
  )
})

# ---- M34: manual pruning, re-pruning, and edges-recomputed-fresh -----------

test_that("manual pruning errors when manual is not a character vector", {
  skip_if_not_installed("psych")
  set.seed(1)
  data <- as.data.frame(matrix(rnorm(600), 100, 6))
  x <- cached(ackwards(data, k_max = 3))
  expect_error(
    prune(x, manual = 1L),
    "character vector"
  )
})

test_that("print.ackwards() and print.summary_ackwards() show the Manual pruning line", {
  skip_if_not_installed("psych")
  set.seed(1)
  data <- as.data.frame(matrix(rnorm(600), 100, 6))
  x <- cached(ackwards(data, k_max = 3))
  lab <- x$levels[["2"]]$labels[1]
  xm <- prune(x, manual = lab)

  expect_no_error(print(xm))
  expect_invisible(print(xm))
  s <- summary(xm)
  expect_equal(s$prune$manual, lab)
  expect_no_error(print(s))
  expect_invisible(print(s))
})

test_that("manual pruning works standalone (rules = 'none')", {
  skip_if_not_installed("psych")
  set.seed(1)
  data <- as.data.frame(matrix(rnorm(600), 100, 6))
  x <- cached(ackwards(data, k_max = 3))

  lab <- x$levels[["2"]]$labels[1]
  xm <- prune(x, manual = lab)

  expect_equal(xm$prune$rules, "none")
  nodes <- xm$prune$nodes
  expect_true(nodes$pruned[nodes$id == lab])
  expect_equal(nodes$prune_reason[nodes$id == lab], "manual")
  # No other nodes are flagged
  expect_equal(sum(nodes$pruned), 1L)
})

test_that("manual pruning combines with an auto rule; auto reason wins on overlap", {
  skip_if_not_installed("psych")
  set.seed(42)
  n <- 500
  g <- rnorm(n)
  s1 <- rnorm(n)
  s2 <- rnorm(n)
  data <- data.frame(
    x1 = 0.9 * g + 0.3 * s1 + rnorm(n, sd = 0.1),
    x2 = 0.9 * g + 0.3 * s1 + rnorm(n, sd = 0.1),
    x3 = 0.9 * g + 0.3 * s1 + rnorm(n, sd = 0.1),
    x4 = 0.9 * g + 0.3 * s2 + rnorm(n, sd = 0.1),
    x5 = 0.9 * g + 0.3 * s2 + rnorm(n, sd = 0.1),
    x6 = 0.9 * g + 0.3 * s2 + rnorm(n, sd = 0.1)
  )
  x <- cached(ackwards(data, k_max = 4))
  x_auto <- suppressMessages(prune(x, "redundant", redundancy_r = 0.9))
  skip_if(!any(x_auto$prune$nodes$pruned), "no redundant nodes found for this seed")

  auto_flagged <- x_auto$prune$nodes$id[x_auto$prune$nodes$pruned]
  unflagged <- x_auto$prune$nodes$id[!x_auto$prune$nodes$pruned]
  skip_if(length(unflagged) == 0L, "no unflagged node available to manually flag")

  x_mixed <- suppressMessages(
    prune(x, "redundant", redundancy_r = 0.9, manual = c(auto_flagged[1], unflagged[1]))
  )
  nodes <- x_mixed$prune$nodes
  # Already-auto-flagged node keeps its "redundant" reason (auto wins on overlap)
  expect_equal(nodes$prune_reason[nodes$id == auto_flagged[1]], "redundant")
  # The otherwise-unflagged node gets prune_reason = "manual"
  expect_true(nodes$pruned[nodes$id == unflagged[1]])
  expect_equal(nodes$prune_reason[nodes$id == unflagged[1]], "manual")
})

test_that("manual pruning errors on unknown factor labels", {
  skip_if_not_installed("psych")
  set.seed(1)
  data <- as.data.frame(matrix(rnorm(600), 100, 6))
  x <- cached(ackwards(data, k_max = 3))
  expect_error(
    prune(x, manual = "not_a_real_factor"),
    "not_a_real_factor"
  )
})

test_that("re-pruning with a new threshold does not mutate the input object or re-extract", {
  skip_if_not_installed("psych")
  set.seed(42)
  n <- 500
  g <- rnorm(n)
  s1 <- rnorm(n)
  s2 <- rnorm(n)
  data <- data.frame(
    x1 = 0.9 * g + 0.3 * s1 + rnorm(n, sd = 0.1),
    x2 = 0.9 * g + 0.3 * s1 + rnorm(n, sd = 0.1),
    x3 = 0.9 * g + 0.3 * s1 + rnorm(n, sd = 0.1),
    x4 = 0.9 * g + 0.3 * s2 + rnorm(n, sd = 0.1),
    x5 = 0.9 * g + 0.3 * s2 + rnorm(n, sd = 0.1),
    x6 = 0.9 * g + 0.3 * s2 + rnorm(n, sd = 0.1)
  )
  x <- cached(ackwards(data, k_max = 4))
  x1 <- suppressMessages(prune(x, "redundant", redundancy_r = 0.99))
  x2 <- suppressMessages(prune(x, "redundant", redundancy_r = 0.5))

  # The original fitted object is never mutated by prune()
  expect_null(x$prune)
  # Re-pruning at a looser threshold flags at least as many nodes
  expect_gte(sum(x2$prune$nodes$pruned), sum(x1$prune$nodes$pruned))
  # No re-extraction: levels/R identical across both prune() calls
  expect_identical(x1$levels, x$levels)
  expect_identical(x2$levels, x$levels)
  expect_identical(x1$r, x$r)
  expect_identical(x2$r, x$r)
  # x$edges is never touched by prune()
  expect_identical(x1$edges, x$edges)
  expect_identical(x2$edges, x$edges)
})

test_that("prune() recomputes all-pairs edges even when fit with default pairs = 'adjacent'", {
  # Regression guard for the M34 design: prune() must not depend on the
  # object having been fit with pairs = "all" -- it recomputes fresh
  # all-pairs edges internally, so the endpoint-r enrichment is available
  # for chains spanning more than one level even on a default-fit object.
  skip_if_not_installed("psych")
  set.seed(42)
  n <- 500
  g <- rnorm(n)
  s1 <- rnorm(n)
  s2 <- rnorm(n)
  data <- data.frame(
    x1 = 0.9 * g + 0.2 * s1 + rnorm(n, sd = 0.05),
    x2 = 0.9 * g + 0.2 * s1 + rnorm(n, sd = 0.05),
    x3 = 0.9 * g + 0.2 * s1 + rnorm(n, sd = 0.05),
    x4 = 0.9 * g + 0.2 * s2 + rnorm(n, sd = 0.05),
    x5 = 0.9 * g + 0.2 * s2 + rnorm(n, sd = 0.05),
    x6 = 0.9 * g + 0.2 * s2 + rnorm(n, sd = 0.05)
  )
  x <- cached(ackwards(data, k_max = 4))
  expect_equal(x$meta$pairs, "adjacent")
  expect_true(all(nchar(names(x$edges$matrices)) == 3L)) # only "k:k+1" adjacent keys

  xp <- suppressMessages(prune(x, "redundant", redundancy_r = 0.9))
  skip_if(is.null(xp$prune$chains), "no chains found for this seed")

  ch <- xp$prune$chains
  long_chains <- unique(ch$chain_id[tapply(ch$level, ch$chain_id, length)[as.character(ch$chain_id)] >= 3L])
  skip_if(length(long_chains) == 0L, "no chain of length >= 3 found for this seed")
  for (cid in long_chains) {
    sub <- ch[ch$chain_id == cid, ]
    expect_false(is.na(sub$endpoint_r[which.max(sub$level)]))
  }
})

# ---- M34 post-review follow-up: clear, de-dup, class, polychoric ESEM --------

test_that("prune(x) clears pruning previously applied to the object", {
  skip_if_not_installed("psych")
  set.seed(1)
  data <- as.data.frame(matrix(rnorm(600), 100, 6))
  x <- cached(ackwards(data, k_max = 3))
  xp <- suppressMessages(prune(x, "redundant"))
  expect_false(is.null(xp$prune))
  # Re-pruning with no auto rule and no manual clears the annotation to NULL
  xc <- prune(xp)
  expect_null(xc$prune)
  expect_s3_class(xc, "ackwards")
})

test_that("manual pruning de-duplicates repeated labels and preserves class", {
  skip_if_not_installed("psych")
  set.seed(1)
  data <- as.data.frame(matrix(rnorm(600), 100, 6))
  x <- cached(ackwards(data, k_max = 3))
  lab <- x$levels[["2"]]$labels[1]
  xm <- prune(x, manual = c(lab, lab))
  expect_s3_class(xm, "ackwards")
  # Repeated label is stored once and flags the node exactly once
  expect_equal(xm$prune$manual, lab)
  nodes <- xm$prune$nodes
  expect_equal(sum(nodes$pruned & nodes$id == lab), 1L)
  expect_equal(nodes$prune_reason[nodes$id == lab], "manual")
})

test_that("prune() works on an ESEM object with a polychoric basis", {
  skip_if_not_installed("lavaan")
  # Exercises the polychoric-R path through compute_edges() inside prune()
  # (edges recomputed from the stored lavaan polychoric matrix, not x$edges).
  d <- .make_ordinal_data()
  x <- suppressWarnings(suppressMessages(
    ackwards(d, k_max = 2, engine = "esem", cor = "polychoric")
  ))
  xp <- suppressWarnings(suppressMessages(prune(x, c("redundant", "artifact"))))

  expect_s3_class(xp, "ackwards")
  expect_false(is.null(xp$prune))
  expect_false(is.null(xp$prune$phi))
  expect_false(is.null(xp$prune$structural))
  # x$edges is never mutated even on the polychoric path
  expect_identical(xp$edges, x$edges)
})

Try the ackwards package in your browser

Any scripts or data that you put into this service are public.

ackwards documentation built on July 25, 2026, 1:08 a.m.