tests/testthat/test-forbes-fidelity.R

# Forbes (2023) fidelity guard (M44).
#
# CLAUDE.md's baseline contract: the default output must reproduce Forbes's
# examples exactly. The fixture holds the Spearman correlation matrices of the
# paper's three simulation studies (regenerated from Forbes's public OSF
# script, set.seed(123)) plus expected outputs computed with her reference
# implementation ("ExtendedBassAckwards functions with annotation.R",
# https://osf.io/pcwm8/) -- see attr(fixture, "provenance"). The tests below
# run only ackwards on the fixed inputs and compare against those expected
# values, so no external code or network is needed at test time.
#
# Correspondence conventions (verified during the M44 feasibility study, where
# the 155-variable applied example also matched to 3.9e-14):
#   * Forbes's comp.corr is t(W_a) %*% R %*% W_b with no sign alignment; ours
#     is the same algebra with primary-parent alignment, so |values| must be
#     identical entrywise (W'RW = I for PCA makes her unstandardized products
#     equal our standardized edges).
#   * Her cong is psych::factor.congruence, which rounds to 2 decimals; ours
#     is exact Tucker's phi, so agreement is within 0.005.
#   * Her component labels are letter-level + index in psych::pca column
#     order ("a1"; "b1","b2"; ...); ours are m{k}f{j} in the same order.
#   * Her comp.corr list enumerates pairs as: for c in 2..K, for i in 1..c-1.

.forbes_fixture <- function() {
  readRDS(test_path("fixtures", "forbes2023_sims.rds"))
}

# Map a Forbes component label ("c3") to an ackwards one ("m3f3").
.forbes_to_ackwards <- function(lab) {
  lv <- match(substr(lab, 1L, 1L), letters)
  paste0("m", lv, "f", substr(lab, 2L, nchar(lab)))
}

# Forbes's redundancy "chase" on an ackwards object: from `node`, follow
# primary-parent links upward while |r| >= .9; return the topmost node
# reached (the node itself when its first upward link is below .9).
.chase <- function(x, node) {
  lv <- as.integer(sub("^m(\\d+)f\\d+$", "\\1", node))
  cur <- node
  while (lv > 1L) {
    E <- x$edges$matrices[[paste0(lv - 1L, ":", lv)]]
    j <- match(cur, colnames(E))
    pi <- which.max(abs(E[, j]))
    if (abs(E[pi, j]) < 0.9) break
    cur <- rownames(E)[pi]
    lv <- lv - 1L
  }
  cur
}

test_that("default output reproduces Forbes's simulation examples exactly", {
  skip_if_not_installed("psych")
  sims <- .forbes_fixture()

  for (nm in names(sims)) {
    sim <- sims[[nm]]
    suppressWarnings(suppressMessages(
      x <- cached(ackwards(sim$R, k_max = 4, pairs = "all"))
    ))

    # (1) Between-level correlations: |ours| == |hers| entrywise, all 6 pairs.
    idx <- 0L
    for (c2 in 2:4) {
      for (i in 1:(c2 - 1L)) {
        idx <- idx + 1L
        E_forbes <- sim$comp_corr[[idx]]
        E_ours <- x$edges$matrices[[paste0(i, ":", c2)]]
        expect_equal(
          abs(unname(E_ours)), abs(unname(E_forbes)),
          tolerance = 1e-12,
          label = paste0(nm, " |edges| ", i, ":", c2, " (ackwards)"),
          expected.label = "Forbes reference"
        )
      }
    }

    # (2) Loading congruence: |ours| == |hers| within her 2-dp rounding.
    phi_ours <- ackwards:::.phi_pairs(x$levels, "all")
    idx <- 0L
    for (c2 in 2:4) {
      for (i in 1:(c2 - 1L)) {
        idx <- idx + 1L
        C_forbes <- sim$cong[[idx]]
        sub <- phi_ours[phi_ours$level_from == i & phi_ours$level_to == c2, ]
        C_ours <- matrix(sub$phi, nrow = i, ncol = c2, byrow = TRUE)
        expect_lt(
          max(abs(abs(C_ours) - abs(unname(C_forbes)))),
          0.005 + 1e-12
        )
      }
    }

    # (3) Redundancy chase paths: for every component, following our
    # primary-parent links upward while |r| >= .9 must land on the same
    # component her ChaseCorrPaths reports ("X--null" = no move).
    for (entry in sim$corr_chase) {
      parts <- strsplit(entry, "--", fixed = TRUE)[[1L]]
      from <- .forbes_to_ackwards(parts[1L])
      expected_top <- if (parts[2L] == "null") from else .forbes_to_ackwards(parts[2L])
      expect_identical(
        .chase(x, from), expected_top,
        label = paste0(nm, " chase(", parts[1L], ") (ackwards)"),
        expected.label = paste0("Forbes '", entry, "'")
      )
    }
  }
})

test_that("prune('redundant') flags Forbes's Simulation 1 chains with her retention rule", {
  skip_if_not_installed("psych")
  sims <- .forbes_fixture()
  suppressWarnings(suppressMessages({
    x <- cached(ackwards(sims$sim1$R, k_max = 4, pairs = "all"))
    xp <- prune(x, "redundant")
  }))

  # Her chase found exactly three redundant links: c3--b2, d1--c1, d2--c2.
  # Under her retention rule: the b2-c3 chain stops short of k_max, keeping
  # the top (m2f2); the two chains reaching k_max keep their bottoms
  # (m4f1, m4f2). Flagged = the other chain members.
  flagged <- xp$prune$nodes$id[xp$prune$nodes$pruned]
  expect_setequal(flagged, c("m3f3", "m3f1", "m3f2"))

  ch <- xp$prune$chains
  expect_setequal(ch$id[ch$retain], c("m2f2", "m4f1", "m4f2"))
})

# ---------------------------------------------------------------------------
# AMH applied example (M53).
#
# Forbes's 155-variable "Assessing Mental Health" applied example, k = 10 (OSF
# pcwm8, CC-BY 4.0). The matrix is the exported dataset `forbes2023` (M54);
# fixtures/forbes2023_amh.rds holds only the expected comp_corr/cong computed
# with HER reference implementation from that same md5-pinned matrix (see
# data-raw/forbes2023.R and attr(fixture, "provenance")). As with the simulations,
# only ackwards() runs here; no Forbes code or network at test time.
#
# Redundancy chase: Forbes's ChaseCorrPaths uses the DIRECT (skip-level)
# correlation to a component at each ancestor level -- the criterion our
# prune("redundant") adopts by default since M53 (redundancy_criterion =
# "direct"). On this deep 10-level hierarchy it diverges from an adjacent-hop
# walk on 7 of 54 components (correlation is non-transitive), so this is exactly
# where the direct criterion earns its keep. amh$corr_chase holds her raw
# ChaseCorrPaths output ("X--Y"; "X--null" = no chase) for all 54 components,
# and .direct_chase() below reproduces it from our all-levels edges. See M53.
.amh_fixture <- function() {
  readRDS(test_path("fixtures", "forbes2023_amh.rds"))$amh
}

# Forbes's direct/skip-level chase on an ackwards object: from `node`, at each
# ancestor level take the node with the largest |direct r| and continue while
# |r| >= 0.9 contiguously; return the topmost node reached (the node itself when
# its best direct link to the next level up is < 0.9). This is what
# redundancy_criterion = "direct" traces internally.
.direct_chase <- function(x, node) {
  lv <- as.integer(sub("^m(\\d+)f\\d+$", "\\1", node))
  cur <- node
  for (j in seq_len(lv - 1L)) {
    E <- x$edges$matrices[[paste0(lv - j, ":", lv)]] # rows level lv-j, cols level lv
    col <- E[, node]
    pi <- which.max(abs(col))
    if (abs(col[pi]) < 0.9) break
    cur <- rownames(E)[pi]
  }
  cur
}

test_that("default output reproduces Forbes's AMH applied example (k = 10)", {
  skip_if_not_installed("psych")
  amh <- .amh_fixture()
  K <- amh$k_max
  suppressWarnings(suppressMessages(
    x <- cached(ackwards(forbes2023, k_max = K, pairs = "all"))
  ))

  # (1) Between-level correlations: |ours| == |hers| entrywise, all 45 pairs.
  idx <- 0L
  for (c2 in 2:K) {
    for (i in 1:(c2 - 1L)) {
      idx <- idx + 1L
      E_forbes <- amh$comp_corr[[idx]]
      E_ours <- x$edges$matrices[[paste0(i, ":", c2)]]
      expect_equal(
        abs(unname(E_ours)), abs(unname(E_forbes)),
        tolerance = 1e-12,
        label = paste0("AMH |edges| ", i, ":", c2, " (ackwards)"),
        expected.label = "Forbes reference"
      )
    }
  }

  # (2) Loading congruence: within her factor.congruence 2-dp rounding.
  phi_ours <- ackwards:::.phi_pairs(x$levels, "all")
  idx <- 0L
  for (c2 in 2:K) {
    for (i in 1:(c2 - 1L)) {
      idx <- idx + 1L
      C_forbes <- amh$cong[[idx]]
      sub <- phi_ours[phi_ours$level_from == i & phi_ours$level_to == c2, ]
      C_ours <- matrix(sub$phi, nrow = i, ncol = c2, byrow = TRUE)
      expect_lt(
        max(abs(abs(C_ours) - abs(unname(C_forbes)))),
        0.005 + 1e-12
      )
    }
  }
})

test_that("direct criterion reproduces Forbes's AMH redundancy chase exactly (54/54)", {
  skip_if_not_installed("psych")
  amh <- .amh_fixture()
  suppressWarnings(suppressMessages(
    x <- cached(ackwards(forbes2023, k_max = amh$k_max, pairs = "all"))
  ))

  # Every component's direct chase over our all-levels edges lands on the same
  # node her ChaseCorrPaths reports ("X--null" = the node stays put). This is
  # the criterion prune("redundant") uses by default; here it is the exact
  # published AMH result, including the 7 components where an adjacent walk
  # would diverge (non-transitivity).
  expect_length(amh$corr_chase, 54L)
  for (entry in amh$corr_chase) {
    parts <- strsplit(entry, "--", fixed = TRUE)[[1L]]
    from <- .forbes_to_ackwards(parts[1L])
    expected_top <- if (parts[2L] == "null") from else .forbes_to_ackwards(parts[2L])
    expect_identical(
      .direct_chase(x, from), expected_top,
      label = paste0("AMH direct chase(", parts[1L], ") (ackwards)"),
      expected.label = paste0("Forbes '", entry, "'")
    )
  }
})

test_that("prune('redundant') on AMH: direct default vs adjacent opt-in", {
  skip_if_not_installed("psych")
  amh <- .amh_fixture()
  suppressWarnings(suppressMessages({
    x <- cached(ackwards(forbes2023, k_max = amh$k_max, pairs = "all"))
    xp <- prune(x, "redundant") # default redundancy_criterion = "direct"
    xa <- prune(x, "redundant", redundancy_criterion = "adjacent")
  }))
  ch <- xp$prune$chains

  # The paper's d4 chain: m4f4 -> ... -> m10f4 (Forbes's d4->e4->f5->g5->h5->i4->j4).
  # It reaches k_max, so the retention rule keeps the most-specific bottom node.
  d4 <- ch[ch$chain_id == ch$chain_id[ch$id == "m10f4"], ]
  expect_setequal(
    d4$id,
    c("m4f4", "m5f4", "m6f5", "m7f5", "m8f5", "m9f4", "m10f4")
  )
  expect_identical(d4$id[d4$retain], "m10f4")

  # Direct (Forbes-faithful) decomposition -- regression pin.
  expect_equal(sum(xp$prune$nodes$pruned), 37L)
  expect_setequal(
    unique(ch$id[ch$retain]),
    c(
      "m1f1", "m4f2", "m5f2", "m7f7",
      "m10f1", "m10f2", "m10f3", "m10f4", "m10f5",
      "m10f6", "m10f7", "m10f8", "m10f9"
    )
  )

  # The adjacent opt-in gives a materially different answer on this deep
  # hierarchy (it retains m3f3, which chases further under the direct rule).
  expect_equal(sum(xa$prune$nodes$pruned), 36L)
  expect_true("m3f3" %in% xa$prune$chains$id[xa$prune$chains$retain])
  expect_false("m3f3" %in% ch$id[ch$retain])
})

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.