tests/testthat/test-backtransform-residual-covariance.R

rescov_test_block <- function(
  group,
  coefficients,
  term,
  covariance = NULL
) {
  block <- list(group = group, coefficients = coefficients, term = term)
  if (!is.null(covariance)) {
    block$covariance <- covariance
  }
  return(block)
}

rescov_test_covariance <- function(variances) {
  n_terms <- length(variances)
  covariance <- array(
    rep(diag(variances), each = 2L),
    dim = c(2L, n_terms, n_terms)
  )
  covariance[2L, , ] <- covariance[2L, , ] * 2
  if (n_terms > 1L) {
    covariance[, 1L, 2L] <- c(0.1, 0.2)
    covariance[, 2L, 1L] <- c(0.1, 0.2)
  }
  return(covariance)
}

test_that("backend adapters return aligned common block records", {
  skip_if_not_installed("glmmTMB")
  skip_if_not_installed("brms")

  data <- expand.grid(
    member = c(-1, 1),
    day = 0:2,
    coupleID = seq_len(20)
  )
  data <- data[order(data$coupleID, data$day, data$member), ]
  data$coupleID <- factor(data$coupleID)
  data$familyID <- factor((as.integer(data$coupleID) - 1L) %/% 4L + 1L)
  data$idiff <- data$member
  data$shared2 <- 1
  data$diff2 <- data$member
  set.seed(123)
  data$outcome <- stats::rnorm(nrow(data))

  glmm_model <- suppressWarnings(glmmTMB::glmmTMB(
    outcome ~ 1 +
      (1 + day | coupleID) +
      (0 + idiff + idiff:day || coupleID) +
      (0 + shared2 | familyID) +
      homdiag(0 + diff2 | familyID),
    dispformula = ~0,
    data = data
  ))
  brms_model <- brms::brm(
    outcome ~ 1 +
      (1 + day | coupleID) +
      (0 + idiff + idiff:day || coupleID) +
      (0 + shared2 | familyID) +
      (0 + diff2 || familyID),
    data = data,
    empty = TRUE
  )

  couple_names <- c("Intercept", "day", "idiff", "idiff:day")
  couple_sd <- matrix(
    seq(1, 1.7, length.out = 8),
    nrow = 2,
    dimnames = list(NULL, couple_names)
  )
  couple_cov <- array(
    0,
    dim = c(2, 4, 4),
    dimnames = list(NULL, couple_names, couple_names)
  )
  for (i in seq_along(couple_names)) {
    couple_cov[, i, i] <- couple_sd[, i]^2
  }
  couple_cov[, 1, 2] <- 0.2
  couple_cov[, 2, 1] <- 0.2

  family_names <- c("shared2", "diff2")
  family_sd <- matrix(
    seq(0.5, 0.8, length.out = 4),
    nrow = 2,
    dimnames = list(NULL, family_names)
  )

  brms_blocks <- testthat::with_mocked_bindings(
    brms_extract_exchangeable_residual_blocks(brms_model),
    VarCorr = function(...) {
      list(
        coupleID = list(sd = couple_sd, cov = couple_cov),
        familyID = list(sd = family_sd)
      )
    },
    .package = "brms"
  )
  glmm_blocks <- glmmTMB_extract_exchangeable_residual_blocks(glmm_model)

  expect_named(glmm_blocks, c("backend", "blocks", "group_ids"))
  expect_named(brms_blocks, c("backend", "blocks", "group_ids"))
  expect_length(glmm_blocks$blocks, 4)
  expect_length(brms_blocks$blocks, 4)
  expect_equal(glmm_blocks$group_ids, brms_blocks$group_ids)
  expect_equal(
    unname(lengths(glmm_blocks$group_ids)),
    rep(nrow(data), 2L)
  )
  expect_equal(
    as.vector(table(glmm_blocks$group_ids$coupleID)),
    rep(6L, 20L)
  )

  descriptor <- function(block) {
    block[c("group", "coefficients", "correlated")]
  }
  expect_equal(
    lapply(glmm_blocks$blocks, descriptor),
    lapply(brms_blocks$blocks, descriptor)
  )
  expect_equal(
    lapply(glmm_blocks$blocks, names),
    lapply(brms_blocks$blocks, names)
  )

  for (i in seq_along(glmm_blocks$blocks)) {
    expect_equal(dim(glmm_blocks$blocks[[i]]$covariance)[1L], 1L)
    expect_equal(dim(brms_blocks$blocks[[i]]$covariance)[1L], 2L)
    expect_equal(
      dim(glmm_blocks$blocks[[i]]$covariance)[2:3],
      dim(brms_blocks$blocks[[i]]$covariance)[2:3]
    )
  }
  expect_equal(
    unname(brms_blocks$blocks[[1L]]$covariance),
    unname(couple_cov[, 1:2, 1:2, drop = FALSE])
  )
  expect_equal(
    unname(brms_blocks$blocks[[2L]]$covariance),
    unname(couple_cov[, 3:4, 3:4, drop = FALSE])
  )
  expect_equal(
    unname(brms_blocks$blocks[[3L]]$covariance[, 1L, 1L]),
    family_sd[, 1L]^2
  )
  expect_equal(
    unname(brms_blocks$blocks[[4L]]$covariance[, 1L, 1L]),
    family_sd[, 2L]^2
  )

  for (blocks in list(glmm_blocks$blocks, brms_blocks$blocks)) {
    pair <- match_supplied_exchangeable_residual_blocks(
      blocks,
      list(
        shared_block = "(1 + day | coupleID)",
        difference_block = "(0 + idiff + idiff:day || coupleID)",
        difference_indicator = "idiff"
      )
    )[[1L]]
    expect_equal(pair$underlying_terms, c("(Intercept)", "day"))

    family_pair <- match_supplied_exchangeable_residual_blocks(
      blocks,
      list(
        shared_block = blocks[[3L]]$term,
        difference_block = blocks[[4L]]$term,
        difference_indicator = "diff2",
        shared_indicator = "shared2"
      )
    )[[1L]]
    expect_equal(family_pair$underlying_terms, "(Intercept)")
  }
})

test_that("glmmTMB extractor warns about non-conditional random effects", {
  skip_if_not_installed("glmmTMB")

  model <- suppressWarnings(glmmTMB::glmmTMB(
    count ~ 1 + (1 | site),
    ziformula = ~1 + (1 | site),
    family = poisson(),
    data = glmmTMB::Salamanders
  ))

  expect_warning(
    extracted <- glmmTMB_extract_exchangeable_residual_blocks(model),
    "non-conditional `glmmTMB` components were ignored (zero-inflation)",
    fixed = TRUE
  )
  expect_length(extracted$blocks, 1L)
  expect_equal(extracted$blocks[[1L]]$group, "site")
})

test_that("literal idiff products match in glmmTMB and brms", {
  skip_if_not_installed("glmmTMB")
  skip_if_not_installed("brms")

  data <- expand.grid(
    member = c(-1, 1),
    day = 0:2,
    coupleID = seq_len(20)
  )
  data$coupleID <- factor(data$coupleID)
  data$idiff <- data$member
  set.seed(456)
  data$outcome <- stats::rnorm(nrow(data))

  glmm_model <- suppressWarnings(glmmTMB::glmmTMB(
    outcome ~ 1 + (1 + day | coupleID) +
      (0 + idiff + I(idiff * day) || coupleID),
    dispformula = ~0,
    data = data
  ))
  brms_model <- brms::brm(
    outcome ~ 1 + (1 + day | coupleID) +
      (0 + idiff + I(idiff * day) || coupleID),
    data = data,
    empty = TRUE
  )

  covariance_names <- unname(brms_model$ranef$coef)
  sd_draws <- matrix(
    1,
    nrow = 2,
    ncol = length(covariance_names),
    dimnames = list(NULL, covariance_names)
  )
  covariance_draws <- array(
    0,
    dim = c(2, 4, 4),
    dimnames = list(NULL, covariance_names, covariance_names)
  )
  for (i in seq_along(covariance_names)) {
    covariance_draws[, i, i] <- 1
  }

  glmm_blocks <- glmmTMB_extract_exchangeable_residual_blocks(glmm_model)
  brms_blocks <- testthat::with_mocked_bindings(
    brms_extract_exchangeable_residual_blocks(brms_model),
    VarCorr = function(...) {
      list(coupleID = list(sd = sd_draws, cov = covariance_draws))
    },
    .package = "brms"
  )

  expected <- list(
    c("(Intercept)", "day"),
    c("idiff", "I(idiff * day)")
  )
  expect_equal(
    lapply(glmm_blocks$blocks, `[[`, "coefficients"),
    expected
  )
  expect_equal(
    lapply(brms_blocks$blocks, `[[`, "coefficients"),
    expected
  )

  for (model_blocks in list(glmm_blocks, brms_blocks)) {
    automatic <- match_blocks_for_exchangeable_indicator(
      model_blocks$blocks,
      idiff = "idiff",
      shared_indicator = "1"
    )[[1L]]
    supplied <- match_supplied_exchangeable_residual_blocks(
      model_blocks$blocks,
      list(
        shared_block = "(1 + day | coupleID)",
        difference_block = "(0 + idiff + I(idiff * day) || coupleID)",
        difference_indicator = "idiff"
      )
    )[[1L]]

    expect_equal(supplied, automatic)
    expect_equal(automatic$underlying_terms, c("(Intercept)", "day"))
  }
})

test_that("unsupported brms random-effect structures fail clearly", {
  skip_if_not_installed("brms")

  data <- data.frame(
    outcome = stats::rnorm(20),
    second_outcome = stats::rnorm(20),
    couple = factor(rep(seq_len(10), each = 2)),
    time = rep(0:1, 10),
    role = factor(rep(rep(c("a", "b"), each = 5), each = 2))
  )

  multivariate <- brms::brm(
    brms::bf(outcome ~ 1 + (1 | couple)) +
      brms::bf(second_outcome ~ 1 + (1 | couple)) +
      brms::set_rescor(FALSE),
    data = data,
    empty = TRUE
  )
  expect_error(
    brms_extract_exchangeable_residual_blocks(multivariate),
    "one response",
    fixed = TRUE
  )

  linked <- brms::brm(
    outcome ~ 1 + (1 | block | couple) + (0 + time | block | couple),
    data = data,
    empty = TRUE
  )
  expect_error(
    brms_extract_exchangeable_residual_blocks(linked),
    "different `| ID |` labels or no shared ID",
    fixed = TRUE
  )

  by_model <- brms::brm(
    outcome ~ 1 + (1 + time | gr(couple, by = role)),
    data = data,
    empty = TRUE
  )
  expect_error(
    brms_extract_exchangeable_residual_blocks(by_model),
    "Refit the exchangeable blocks without `by`",
    fixed = TRUE
  )
})

test_that("brms ignores non-mean random effects", {
  skip_if_not_installed("brms")

  data <- data.frame(
    outcome = stats::rnorm(20),
    couple = factor(rep(seq_len(10), each = 2)),
    time = rep(0:1, 10)
  )
  model <- brms::brm(
    brms::bf(
      outcome ~ 1 + (1 + time | mean | couple),
      sigma ~ 1 + (1 | scale | couple)
    ),
    data = data,
    empty = TRUE
  )

  names <- c("Intercept", "time", "sigma_Intercept")
  sd <- matrix(1, nrow = 2, ncol = 3, dimnames = list(NULL, names))
  covariance <- array(
    0,
    dim = c(2, 3, 3),
    dimnames = list(NULL, names, names)
  )
  for (i in seq_along(names)) {
    covariance[, i, i] <- 1
  }

  expect_warning(
    blocks <- testthat::with_mocked_bindings(
      brms_extract_exchangeable_residual_blocks(model),
      VarCorr = function(...) {
        list(couple = list(sd = sd, cov = covariance))
      },
      .package = "brms"
    ),
    "only processes ordinary response-mean random effects",
    fixed = TRUE
  )
  expect_length(blocks$blocks, 1L)
  expect_equal(blocks$blocks[[1L]]$coefficients, c("(Intercept)", "time"))
})

test_that("automatic matching aligns groups and coefficient order", {
  marker <- ".member_contrast_assumed_exchangeable_arbitrary"
  blocks <- list(
    rescov_test_block("coupleID", c("time", "(Intercept)"), "shared"),
    rescov_test_block(
      "coupleID",
      c(marker, paste0("I(", marker, " * time)")),
      "difference"
    ),
    rescov_test_block("coupleID", "support", "unrelated"),
    rescov_test_block("studyID", c("(Intercept)", "time"), "other group")
  )

  pair <- match_exchangeable_residual_blocks(blocks)[[1L]]
  expect_equal(pair$shared_block_index, 1L)
  expect_equal(pair$difference_block_index, 2L)
  expect_equal(pair$difference_indicator, marker)
  expect_equal(pair$shared_indicator, "1")
  expect_equal(pair$underlying_terms, c("time", "(Intercept)"))
  expect_equal(pair$difference_term_indices, c(2L, 1L))

  expect_equal(
    exchangeable_underlying_terms(
      c("IDIFF", "I(IDIFF * time)"),
      "IDIFF"
    ),
    c("(Intercept)", "time")
  )
  expect_equal(
    exchangeable_underlying_terms("I(time * IDIFF)", "IDIFF"),
    "time"
  )

  nonsyntactic_indicators <- c("my diff", "difference-indicator", "1difference")
  for (indicator in nonsyntactic_indicators) {
    quoted_indicator <- paste0("`", indicator, "`")
    expect_equal(
      exchangeable_underlying_terms(
        c(quoted_indicator, paste0(quoted_indicator, ":`study time`")),
        indicator
      ),
      c("(Intercept)", "study time")
    )
  }
})

test_that("automatic matching recognizes compact exchangeable columns", {
  shared_indicator <- paste0(dyad_retained_prefix, "is_exchangeable")
  difference_indicator <- paste0(
    dyad_retained_prefix,
    "member_contrast_arbitrary"
  )
  blocks <- list(
    rescov_test_block("coupleID", shared_indicator, "shared"),
    rescov_test_block("coupleID", difference_indicator, "difference")
  )

  pair <- match_exchangeable_residual_blocks(blocks)[[1L]]

  expect_equal(pair$shared_block_index, 1L)
  expect_equal(pair$difference_block_index, 2L)
  expect_equal(pair$shared_indicator, shared_indicator)
  expect_equal(pair$difference_indicator, difference_indicator)
})

test_that("automatic matching handles compositions and grouping levels", {
  same_idiff <- ".member_contrast_same_sex_arbitrary"
  friend_idiff <- ".member_contrast_friends_arbitrary"
  blocks <- list(
    rescov_test_block("coupleID", "(Intercept)", "generic"),
    rescov_test_block("coupleID", ".is_same_sex", "same shared"),
    rescov_test_block("coupleID", same_idiff, "same difference"),
    rescov_test_block("coupleID", ".is_friends", "friend shared"),
    rescov_test_block("coupleID", friend_idiff, "friend difference")
  )

  pairs <- match_exchangeable_residual_blocks(blocks)
  expect_equal(
    lapply(
      pairs,
      function(pair) pair[c("shared_block_index", "difference_block_index")]
    ),
    list(
      list(shared_block_index = 2L, difference_block_index = 3L),
      list(shared_block_index = 4L, difference_block_index = 5L)
    )
  )
  expect_equal(
    unlist(lapply(pairs, `[[`, "shared_indicator"), use.names = FALSE),
    c(".is_same_sex", ".is_friends")
  )

  repeated <- list(
    rescov_test_block("coupleID", "(Intercept)", "stable shared"),
    rescov_test_block("coupleID", same_idiff, "stable difference"),
    rescov_test_block("coupleID:day", "(Intercept)", "occasion shared"),
    rescov_test_block("coupleID:day", same_idiff, "occasion difference")
  )
  repeated_pairs <- match_exchangeable_residual_blocks(repeated)
  expect_equal(
    lapply(repeated_pairs, `[[`, "shared_block_index"),
    list(1L, 3L)
  )
  expect_equal(
    lapply(repeated_pairs, `[[`, "difference_block_index"),
    list(2L, 4L)
  )
})

test_that("automatic matching is conservative about missing and ambiguous blocks", {
  marker <- ".member_contrast_assumed_exchangeable_arbitrary"

  expect_false(
    is_generated_exchangeable_difference_indicator(
      ".member_contrast__arbitrary"
    )
  )

  expect_error(
    find_exchangeable_difference_indicator(c(
      ".member_contrast_friends_arbitrary",
      ".member_contrast_coworkers_arbitrary"
    )),
    "more than one generated difference indicator",
    fixed = TRUE
  )

  expect_error(
    match_exchangeable_residual_blocks(list(
      rescov_test_block("coupleID", "(Intercept)", "shared")
    )),
    "Supply `block_pairings` explicitly",
    fixed = TRUE
  )

  incomplete <- list(
    rescov_test_block(
      "coupleID",
      c("(Intercept)", "time", "support"),
      "shared"
    ),
    rescov_test_block(
      "coupleID",
      c(marker, paste0(marker, ":time")),
      "difference"
    )
  )
  expect_error(
    match_exchangeable_residual_blocks(incomplete),
    "Supply `block_pairings` explicitly to select the intended blocks",
    fixed = TRUE
  )

  ambiguous <- list(
    rescov_test_block("coupleID", "(Intercept)", "shared one"),
    rescov_test_block("coupleID", "(Intercept)", "shared two"),
    rescov_test_block("coupleID", marker, "difference")
  )
  expect_error(
    match_exchangeable_residual_blocks(ambiguous),
    "Supply `block_pairings` explicitly to select the intended blocks",
    fixed = TRUE
  )

  partial <- list(
    rescov_test_block("coupleID", c("(Intercept)", "time"), "shared"),
    rescov_test_block("coupleID", c(marker, "time"), "partial difference")
  )
  expect_error(
    match_exchangeable_residual_blocks(partial),
    "must identify every coefficient in its difference block",
    fixed = TRUE
  )

  expect_error(
    exchangeable_underlying_terms(
      c("IDIFF:time", "I(IDIFF * time)"),
      "IDIFF"
    ),
    "both represent the underlying term `time`. Keep only one representation",
    fixed = TRUE
  )
})

test_that("supplied exact pairs align partial and custom-named blocks", {
  shared_covariance <- rescov_test_covariance(1:3)
  difference_covariance <- rescov_test_covariance(4:6)

  blocks <- list(
    rescov_test_block(
      "coupleID",
      c("time", "(Intercept)", "support"),
      "us(time + 1 + support | coupleID)",
      shared_covariance
    ),
    rescov_test_block(
      "coupleID",
      c("I(IDIFF * support)", "IDIFF", "IDIFF:stress"),
      "diag(0 + I(IDIFF * support) + IDIFF + IDIFF:stress | coupleID)",
      difference_covariance
    )
  )

  pair <- match_supplied_exchangeable_residual_blocks(
    blocks,
    list(
      shared_block = "(1 + support + time | coupleID)",
      difference_block =
        "(0 + IDIFF + I(support * IDIFF) + IDIFF:stress || coupleID)",
      difference_indicator = "IDIFF"
    )
  )[[1L]]
  expect_equal(
    pair$underlying_terms,
    c("time", "(Intercept)", "support", "stress")
  )
  expect_equal(pair$shared_term_indices, c(1L, 2L, 3L, NA_integer_))
  expect_equal(
    pair$difference_term_indices,
    c(NA_integer_, 2L, 1L, 3L)
  )

  aligned <- align_exchangeable_pair_covariances(blocks, pair)
  expect_equal(
    unname(aligned$shared[, 1:3, 1:3, drop = FALSE]),
    unname(shared_covariance)
  )
  expect_true(all(aligned$shared[, 4, ] == 0))
  expect_true(all(aligned$shared[, , 4] == 0))
  expect_equal(
    unname(aligned$difference[
      , c(3, 2, 4), c(3, 2, 4), drop = FALSE
    ]),
    unname(difference_covariance)
  )
  expect_true(all(aligned$difference[, 1, ] == 0))
  expect_true(all(aligned$difference[, , 1] == 0))
  expect_equal(
    dimnames(aligned$shared)[2:3],
    list(pair$underlying_terms, pair$underlying_terms)
  )

  # The final transformation uses the aligned order, including every padded
  # zero, rather than either block's original coefficient order.
  transformed <- backtransform_exchangeable_covariances(
    aligned,
    pair$underlying_terms
  )
  member_1_indices <- seq_along(pair$underlying_terms)
  member_2_indices <- length(member_1_indices) + member_1_indices
  expect_equal(
    transformed[, member_1_indices, member_1_indices],
    aligned$shared + aligned$difference,
    ignore_attr = TRUE
  )
  expect_equal(
    transformed[, member_1_indices, member_2_indices],
    aligned$shared - aligned$difference,
    ignore_attr = TRUE
  )

  blocks[[2L]]$covariance <- array(0, dim = c(3, 3, 3))
  expect_error(
    align_exchangeable_pair_covariances(blocks, pair),
    "different numbers of estimates or posterior draws",
    fixed = TRUE
  )

  composition_blocks <- list(
    rescov_test_block(
      "coupleID",
      c("SAMESEX:time", "SAMESEX"),
      "shared"
    ),
    rescov_test_block(
      "coupleID",
      c("IDIFF_SAMESEX", "time:IDIFF_SAMESEX"),
      "difference"
    )
  )
  composition_pair <- match_supplied_exchangeable_residual_blocks(
    composition_blocks,
    list(
      shared_block = "shared",
      difference_block = "difference",
      difference_indicator = "IDIFF_SAMESEX",
      shared_indicator = "SAMESEX"
    )
  )[[1L]]
  expect_equal(
    composition_pair$underlying_terms,
    c("time", "(Intercept)")
  )
  expect_equal(composition_pair$difference_term_indices, c(2L, 1L))
})

test_that("supplied exact pairs support wholly omitted blocks", {
  marker <- ".member_contrast_assumed_exchangeable_arbitrary"
  shared_covariance <- rescov_test_covariance(1:2)
  difference_covariance <- rescov_test_covariance(3:4)
  blocks <- list(
    rescov_test_block(
      "coupleID",
      c("(Intercept)", "time"),
      "us(1 + time | coupleID)",
      shared_covariance
    ),
    rescov_test_block(
      "coupleID:day",
      c(paste0(marker, ":time"), marker),
      paste0("diag(0 + ", marker, ":time + ", marker, " | coupleID:day)"),
      difference_covariance
    ),
    rescov_test_block("studyID", "(Intercept)", "us(1 | studyID)")
  )

  expect_warning(
    shared_only <- match_supplied_exchangeable_residual_blocks(
      blocks,
      list(
        shared_block = "(1 + time | coupleID)",
        difference_block = NULL
      ),
      model_frame = data.frame(other = 1:2)
    ),
    "Use `NULL` only if that block was omitted from the fitted model",
    fixed = TRUE
  )
  difference_only <- match_supplied_exchangeable_residual_blocks(
    blocks,
    list(
      shared_block = NULL,
      difference_block = paste0(
        "(0 + ", marker, " + ", marker, ":time || coupleID:day)"
      ),
      difference_indicator = marker
    )
  )
  matched <- c(shared_only, difference_only)

  expect_equal(matched[[1L]]$shared_block_index, 1L)
  expect_true(is.na(matched[[1L]]$difference_block_index))
  expect_null(matched[[1L]]$difference_indicator)
  expect_equal(matched[[1L]]$underlying_terms, c("(Intercept)", "time"))
  expect_equal(matched[[1L]]$shared_term_indices, c(1L, 2L))
  expect_true(all(is.na(matched[[1L]]$difference_term_indices)))

  expect_true(is.na(matched[[2L]]$shared_block_index))
  expect_equal(matched[[2L]]$difference_block_index, 2L)
  expect_equal(matched[[2L]]$underlying_terms, c("time", "(Intercept)"))
  expect_true(all(is.na(matched[[2L]]$shared_term_indices)))
  expect_equal(matched[[2L]]$difference_term_indices, c(1L, 2L))

  aligned_shared_only <- align_exchangeable_pair_covariances(
    blocks,
    matched[[1L]]
  )
  expect_equal(
    unname(aligned_shared_only$shared),
    unname(shared_covariance)
  )
  expect_true(all(aligned_shared_only$difference == 0))

  aligned_difference_only <- align_exchangeable_pair_covariances(
    blocks,
    matched[[2L]]
  )
  expect_true(all(aligned_difference_only$shared == 0))
  expect_equal(
    unname(aligned_difference_only$difference),
    unname(difference_covariance)
  )
})

test_that("omitted difference warnings are emitted separately", {
  blocks <- list(
    rescov_test_block("coupleID", "(Intercept)", "first shared"),
    rescov_test_block("familyID", "(Intercept)", "second shared")
  )

  warnings <- testthat::capture_warnings(
    match_supplied_exchangeable_residual_blocks(
      blocks,
      list(
        first = list(shared_block = "first shared", difference_block = NULL),
        second = list(shared_block = "second shared", difference_block = NULL)
      )
    )
  )

  expect_length(warnings, 2L)
  expect_match(warnings[[1L]], "`block_pairings[[\"first\"]]$difference_block`", fixed = TRUE)
  expect_false(grepl("second", warnings[[1L]], fixed = TRUE))
  expect_match(warnings[[2L]], "`block_pairings[[\"second\"]]$difference_block`", fixed = TRUE)
  expect_false(grepl("first", warnings[[2L]], fixed = TRUE))
})

test_that("aligned covariance arrays back-transform to member coordinates", {
  terms <- c("(Intercept)", "time")
  shared <- array(0, dim = c(2, 2, 2))
  difference <- array(0, dim = c(2, 2, 2))

  shared[1, , ] <- matrix(c(1.2, 0.2, 0.2, 0.8), 2)
  difference[1, , ] <- matrix(c(0.3, 0.1, 0.1, 0.4), 2)
  shared[2, , ] <- matrix(c(2.0, -0.1, -0.1, 1.0), 2)
  difference[2, , ] <- matrix(c(0.5, 0.2, 0.2, 0.6), 2)

  varcov <- backtransform_exchangeable_covariances(
    list(shared = shared, difference = difference),
    terms
  )
  expect_equal(dim(varcov), c(2L, 4L, 4L))
  expect_equal(
    dimnames(varcov)[2:3],
    rep(list(c(
      "member_1: (Intercept)", "member_1: time",
      "member_2: (Intercept)", "member_2: time"
    )), 2L)
  )

  # Compare both draws with the equivalent matrix expression
  # T %*% diag(shared, difference) %*% t(T).
  identity <- diag(2)
  transformation <- rbind(
    cbind(identity, identity),
    cbind(identity, -identity)
  )
  for (draw in 1:2) {
    component_covariance <- matrix(0, 4, 4)
    component_covariance[1:2, 1:2] <- shared[draw, , ]
    component_covariance[3:4, 3:4] <- difference[draw, , ]
    expected <- transformation %*%
      component_covariance %*%
      t(transformation)
    transformed <- unname(varcov[draw, , ])
    expect_equal(transformed, expected)
    expect_true(isSymmetric(transformed))
    expect_true(all(
      eigen(transformed, symmetric = TRUE, only.values = TRUE)$values >=
        -sqrt(.Machine$double.eps)
    ))
  }
})

test_that("the SD-correlation representation handles boundaries explicitly", {
  shared <- array(1.2, dim = c(1, 1, 1))
  difference <- array(0.3, dim = c(1, 1, 1))
  varcov <- backtransform_exchangeable_covariances(
    list(shared = shared, difference = difference),
    "(Intercept)"
  )
  sdcor <- covariance_array_to_sdcor(varcov)

  expect_equal(
    unname(varcov[1, , ]),
    matrix(c(1.5, 0.9, 0.9, 1.5), 2)
  )
  expect_equal(unname(diag(sdcor[1, , ])), rep(sqrt(1.5), 2))
  expect_equal(sdcor[1, 1, 2], 0.6)
  expect_equal(sdcor[1, 2, 1], 0.6)

  equal_components <- backtransform_exchangeable_covariances(
    list(
      shared = array(1, dim = c(1, 1, 1)),
      difference = array(1, dim = c(1, 1, 1))
    ),
    "(Intercept)"
  )
  expect_equal(covariance_array_to_sdcor(equal_components)[1, 1, 2], 0)

  difference_only <- backtransform_exchangeable_covariances(
    list(
      shared = array(0, dim = c(1, 1, 1)),
      difference = array(1, dim = c(1, 1, 1))
    ),
    "(Intercept)"
  )
  expect_equal(covariance_array_to_sdcor(difference_only)[1, 1, 2], -1)

  zero_variance <- array(
    matrix(c(0, 0, 0, 1), 2),
    dim = c(1, 2, 2)
  )
  zero_sdcor <- covariance_array_to_sdcor(zero_variance)
  expect_equal(diag(zero_sdcor[1, , ]), c(0, 1))
  expect_true(is.na(zero_sdcor[1, 1, 2]))
  expect_true(is.na(zero_sdcor[1, 2, 1]))

  negative_variance <- zero_variance
  negative_variance[1, 1, 1] <- -0.1
  expect_error(
    covariance_array_to_sdcor(negative_variance),
    "contains a negative variance",
    fixed = TRUE
  )
})

test_that("back-transformation validates aligned covariance arrays", {
  symmetric <- array(
    matrix(c(1, 0.2, 0.2, 1), 2),
    dim = c(1, 2, 2)
  )

  nearly_symmetric <- symmetric
  nearly_symmetric[1, 1, 2] <-
    nearly_symmetric[1, 1, 2] + 5e-5
  transformed <- backtransform_exchangeable_covariances(
    list(shared = nearly_symmetric, difference = symmetric),
    c("(Intercept)", "time")
  )
  expect_identical(
    unname(transformed),
    unname(aperm(transformed, c(1L, 3L, 2L)))
  )

  asymmetric <- symmetric
  asymmetric[1, 1, 2] <- 0.4

  expect_error(
    backtransform_exchangeable_covariances(
      list(shared = asymmetric, difference = symmetric),
      c("(Intercept)", "time")
    ),
    "shared covariance array must be symmetric",
    fixed = TRUE
  )

  nonfinite <- symmetric
  nonfinite[1, 1, 1] <- NA_real_
  expect_error(
    backtransform_exchangeable_covariances(
      list(shared = nonfinite, difference = symmetric),
      c("(Intercept)", "time")
    ),
    "must be finite arrays with identical draw and square term dimensions",
    fixed = TRUE
  )
})

test_that("omitted blocks are checked without rejecting disjoint pairs", {
  blocks <- list(
    rescov_test_block("coupleID", "time", "shared time"),
    rescov_test_block("coupleID", "IDIFF:support", "difference support")
  )
  matched <- match_supplied_exchangeable_residual_blocks(
    blocks,
    list(
      list(
        shared_block = "shared time",
        difference_block = NULL,
        difference_indicator = "IDIFF"
      ),
      list(
        shared_block = NULL,
        difference_block = "difference support",
        difference_indicator = "IDIFF"
      )
    )
  )
  expect_length(matched, 2L)

  malformed_difference <- list(
    rescov_test_block("coupleID", "(Intercept)", "shared"),
    rescov_test_block(
      "coupleID",
      c("IDIFF", "time"),
      "partial difference"
    )
  )
  expect_error(
    match_supplied_exchangeable_residual_blocks(
      malformed_difference,
      list(shared_block = "shared", difference_block = NULL, difference_indicator = "IDIFF")
    ),
    "`block_pairings$difference_block` is `NULL`, but a compatible fitted block exists",
    fixed = TRUE
  )

  unsupported_product <- list(
    rescov_test_block("coupleID", "time", "shared"),
    rescov_test_block(
      "coupleID",
      "I(IDIFF * time^2)",
      "unsupported difference"
    )
  )
  expect_error(
    match_supplied_exchangeable_residual_blocks(
      unsupported_product,
      list(shared_block = "shared", difference_block = NULL, difference_indicator = "IDIFF")
    ),
    "`block_pairings$difference_block` is `NULL`, but a compatible fitted block exists",
    fixed = TRUE
  )

  malformed_shared <- list(
    rescov_test_block(
      "coupleID",
      c("SAMESEX", "time"),
      "partial shared"
    ),
    rescov_test_block("coupleID", "IDIFF", "difference")
  )
  expect_error(
    match_supplied_exchangeable_residual_blocks(
      malformed_shared,
      list(
        shared_block = NULL,
        difference_block = "difference",
        difference_indicator = "IDIFF",
        shared_indicator = "SAMESEX"
      )
    ),
    "`block_pairings$shared_block` is `NULL`, but a compatible fitted block exists",
    fixed = TRUE
  )
})

test_that("model-style selectors preserve covariance structures", {
  marker <- ".member_contrast_assumed_exchangeable_arbitrary"
  blocks <- list(
    rescov_test_block(
      "coupleID",
      c("time", "(Intercept)"),
      "us(time + 1 | coupleID)"
    ),
    rescov_test_block(
      "coupleID",
      c(paste0(marker, ":time"), marker),
      paste0("diag(0 + ", marker, ":time + ", marker, " | coupleID)")
    )
  )

  pair <- match_supplied_exchangeable_residual_blocks(
    blocks,
    list(
      shared_block = "(1 + time | coupleID)",
      difference_block = paste0(
        "(0 + ", marker, " + time:", marker, " || coupleID)"
      ),
      difference_indicator = marker
    )
  )[[1L]]
  expect_equal(pair$shared_block_index, 1L)
  expect_equal(pair$difference_block_index, 2L)
  expect_equal(pair$difference_term_indices, c(1L, 2L))

  expect_equal(
    canonicalize_exchangeable_block_term("homdiag(0 + x | group)"),
    "homdiag(0+x|group)"
  )
  expect_false(identical(
    canonicalize_exchangeable_block_term("homdiag(0 + x | group)"),
    canonicalize_exchangeable_block_term("(0 + x || group)")
  ))

  ambiguous <- list(
    rescov_test_block(
      "coupleID",
      c("(Intercept)", "time"),
      "us(time + 1 | coupleID)"
    ),
    rescov_test_block(
      "coupleID",
      c("(Intercept)", "time"),
      "us(1 + time | coupleID)"
    ),
    rescov_test_block("coupleID", marker, "difference")
  )
  expect_error(
    match_supplied_exchangeable_residual_blocks(
      ambiguous,
      list(
        shared_block = "(1+time | coupleID)",
        difference_block = "difference",
        difference_indicator = marker
      )
    ),
    "Refit without duplicate equivalent random-effect blocks",
    fixed = TRUE
  )
})

test_that("supplied pairs accept arbitrary difference-indicator names", {
  pair <- list(
    shared_block = "(1 | coupleID)",
    difference_block = "(0 + hallelujah | coupleID)",
    difference_indicator = "hallelujah"
  )

  single <- normalize_supplied_exchangeable_pairs(pair)
  expect_equal(single[[1L]]$difference_indicator, "hallelujah")
  expect_equal(single[[1L]]$shared_indicator, "1")
  expect_equal(attr(single, "pair_labels"), "`block_pairings`")

  multiple <- normalize_supplied_exchangeable_pairs(list(
    stable = pair,
    shared = pair
  ))
  expect_equal(names(multiple), c("stable", "shared"))
  expect_equal(
    attr(multiple, "pair_labels"),
    c("`block_pairings[[\"stable\"]]`", "`block_pairings[[\"shared\"]]`")
  )
})

test_that("block pairing names are stable, partial, and unique", {
  pair <- list(
    shared_block = "(1 | coupleID)",
    difference_block = "(0 + IDIFF | coupleID)",
    difference_indicator = "IDIFF"
  )

  expect_named(normalize_supplied_exchangeable_pairs(pair), "pair_1")
  expect_named(
    normalize_supplied_exchangeable_pairs(list(pair, pair)),
    c("pair_1", "pair_2")
  )
  partially_named <- stats::setNames(list(pair, pair), c("dyad", ""))
  expect_named(
    normalize_supplied_exchangeable_pairs(partially_named),
    c("dyad", "pair_2")
  )
  missing_name <- stats::setNames(list(pair, pair), c(NA_character_, "dyad"))
  expect_named(
    normalize_supplied_exchangeable_pairs(missing_name),
    c("pair_1", "dyad")
  )
  duplicate_final <- stats::setNames(list(pair, pair), c("pair_2", ""))
  expect_error(
    normalize_supplied_exchangeable_pairs(duplicate_final),
    "Duplicated name(s): pair_2",
    fixed = TRUE
  )
})

test_that("supplied pair specifications fail clearly", {
  blocks <- list(
    rescov_test_block("coupleID", "(Intercept)", "shared"),
    rescov_test_block("coupleID", "IDIFF", "difference"),
    rescov_test_block("study", "(Intercept)", "study")
  )

  expect_error(
    normalize_supplied_exchangeable_pairs(NULL),
    "must be one named block pairing or a list of named block pairings",
    fixed = TRUE
  )
  expect_error(
    normalize_supplied_exchangeable_pairs(list(list("shared", "difference"))),
    "must be a named list with fields",
    fixed = TRUE
  )
  expect_error(
    normalize_supplied_exchangeable_pairs(list(
      shared_block = "shared",
      difference_block = "difference",
      difference_indicator = character()
    )),
    "difference_indicator` must be one non-empty string",
    fixed = TRUE
  )
  expect_error(
    normalize_supplied_exchangeable_pairs(list(
      shared_block = "shared",
      difference_block = "difference",
      difference_indicator = "IDIFF",
      shared_indicator = "IDIFF"
    )),
    "must name different columns",
    fixed = TRUE
  )
  expect_error(
    normalize_supplied_exchangeable_pairs(list(
      shared_block = 1L,
      difference_block = "difference",
      difference_indicator = "IDIFF"
    )),
    "shared_block` must be one random-effect term selector",
    fixed = TRUE
  )

  expect_error(
    recover_exchangeable_covariance(stats::lm(mpg ~ wt, data = mtcars)),
    "must be a fitted `glmmTMB` or `brmsfit` model",
    fixed = TRUE
  )

  expect_error(
    normalize_supplied_exchangeable_pairs(list(
      shared = "shared",
      difference = "difference"
    )),
    "unknown fields `shared`, `difference`",
    fixed = TRUE
  )

  expect_error(
    match_supplied_exchangeable_residual_blocks(
      blocks,
      list(shared_block = "shared", difference_block = "difference")
    ),
    paste0(
      "`block_pairings` is missing required field `difference_indicator`. Set it to ",
      "the exact name of the -1/+1 difference-indicator column used in the ",
      "selected difference block, for example ",
      "`difference_indicator = \"hallelujah\"`."
    ),
    fixed = TRUE
  )
  expect_error(
    match_supplied_exchangeable_residual_blocks(
      blocks,
      list(list(shared_block = "shared", difference_block = "difference"))
    ),
    "`block_pairings[[1]]` is missing required field `difference_indicator`",
    fixed = TRUE
  )
  expect_error(
    normalize_supplied_exchangeable_pairs(list(
      shared_block = "shared",
      difference_block = "difference",
      difference_indicator = NULL
    )),
    "`block_pairings` is missing required field `difference_indicator`",
    fixed = TRUE
  )
  omitted_difference <- normalize_supplied_exchangeable_pairs(
    list(shared_block = "shared", difference_block = NULL)
  )
  expect_null(omitted_difference[[1L]]$difference_indicator)
  expect_error(
    match_supplied_exchangeable_residual_blocks(
      blocks,
      list(
        shared_block = "shared",
        difference_block = "difference",
        idif = "IDIFF"
      )
    ),
    paste0(
      "`block_pairings` contains unknown field `idif`. Allowed fields are `shared_block`, ",
      "`difference_block`, `difference_indicator`, `shared_indicator`."
    ),
    fixed = TRUE
  )
  expect_error(
    match_supplied_exchangeable_residual_blocks(
      blocks,
      list(shared_indicator = "1")
    ),
    "`block_pairings` is missing required fields `shared_block`, `difference_block`.",
    fixed = TRUE
  )
  expect_error(
    match_supplied_exchangeable_residual_blocks(
      blocks,
      list(
        shared_block = "unknown",
        difference_block = "difference",
        difference_indicator = "IDIFF"
      )
    ),
    "Copy the intended term from the available blocks below",
    fixed = TRUE
  )
  expect_error(
    match_supplied_exchangeable_residual_blocks(
      blocks,
      list(
        shared_block = "study",
        difference_block = "difference",
        difference_indicator = "IDIFF"
      )
    ),
    "shared block groups by `study`, but the selected difference block groups by `coupleID`",
    fixed = TRUE
  )

  transformed <- list(
    rescov_test_block("coupleID", c("(Intercept)", "time"), "shared"),
    rescov_test_block(
      "coupleID",
      c("IDIFF", "I(IDIFF * time^2)"),
      "difference"
    )
  )
  expect_error(
    match_supplied_exchangeable_residual_blocks(
      transformed,
      list(
        shared_block = "shared",
        difference_block = "difference",
        difference_indicator = "IDIFF"
      )
    ),
    "must identify every coefficient",
    fixed = TRUE
  )

  expect_error(
    match_supplied_exchangeable_residual_blocks(
      blocks,
      list(shared_block = NULL, difference_block = NULL, difference_indicator = "IDIFF")
    ),
    "cannot set both `shared_block` and `difference_block` to `NULL`",
    fixed = TRUE
  )

  expect_error(
    match_supplied_exchangeable_residual_blocks(
      blocks,
      list(shared_block = "shared", difference_block = NULL, difference_indicator = "IDIFF")
    ),
    "`block_pairings$difference_block` is `NULL`, but a compatible fitted block exists",
    fixed = TRUE
  )
  expect_error(
    match_supplied_exchangeable_residual_blocks(
      blocks,
      list(shared_block = NULL, difference_block = "difference", difference_indicator = "IDIFF")
    ),
    "`block_pairings$shared_block` is `NULL`, but a compatible fitted block exists",
    fixed = TRUE
  )
  expect_error(
    match_supplied_exchangeable_residual_blocks(
      blocks,
      list(shared_block = "difference", difference_block = NULL, difference_indicator = "IDIFF")
    ),
    "selected shared block contains the difference indicator `IDIFF`",
    fixed = TRUE
  )

  repeated_pair <- list(
    shared_block = "shared",
    difference_block = "difference",
    difference_indicator = "IDIFF"
  )
  expect_error(
    match_supplied_exchangeable_residual_blocks(
      blocks,
      list(repeated_pair, repeated_pair)
    ),
    "Reused blocks: `shared`, `difference`. Remove each reused block",
    fixed = TRUE
  )
})

test_that("named supplied pairs retain their labels in downstream diagnostics", {
  blocks <- list(
    rescov_test_block("coupleID", "(Intercept)", "shared"),
    rescov_test_block("coupleID", "IDIFF", "difference"),
    rescov_test_block("study", "(Intercept)", "study")
  )

  expect_error(
    match_supplied_exchangeable_residual_blocks(
      blocks,
      list(stable = list(
        shared_block = "study",
        difference_block = "difference",
        difference_indicator = "IDIFF"
      ))
    ),
    paste0(
      "`block_pairings[[\"stable\"]]`: The selected shared block groups by `study`, ",
      "but the selected difference block groups by `coupleID`"
    ),
    fixed = TRUE
  )

  malformed_coefficients <- list(
    rescov_test_block("coupleID", "(Intercept)", "shared"),
    rescov_test_block(
      "coupleID",
      c("IDIFF", "I(IDIFF * time^2)"),
      "difference"
    )
  )
  expect_error(
    match_supplied_exchangeable_residual_blocks(
      malformed_coefficients,
      list(stable = list(
        shared_block = "shared",
        difference_block = "difference",
        difference_indicator = "IDIFF"
      ))
    ),
    "`block_pairings[[\"stable\"]]`: Difference indicator `IDIFF` must identify every coefficient",
    fixed = TRUE
  )

  stable_pair <- list(
    shared_block = "shared",
    difference_block = "difference",
    difference_indicator = "IDIFF"
  )
  expect_warning(
    match_supplied_exchangeable_residual_blocks(
      blocks,
      list(stable = stable_pair),
      model_frame = data.frame(other = 1:2)
    ),
    "`block_pairings[[\"stable\"]]`: `IDIFF` was not retained",
    fixed = TRUE
  )
  expect_error(
    match_supplied_exchangeable_residual_blocks(
      blocks,
      list(stable = stable_pair),
      model_frame = data.frame(IDIFF = c(1, 1))
    ),
    "`block_pairings[[\"stable\"]]`: `IDIFF` must contain both -1 and +1",
    fixed = TRUE
  )
  expect_error(
    match_supplied_exchangeable_residual_blocks(
      blocks,
      list(stable = stable_pair),
      model_frame = data.frame(IDIFF = c(-1, -1, 1, 1)),
      group_ids = list(coupleID = rep(1:2, each = 2L))
    ),
    "No supported fitted `coupleID` group contains both -1 and +1",
    fixed = TRUE
  )

  expect_error(
    match_supplied_exchangeable_residual_blocks(
      blocks,
      list(stable = stable_pair, follow_up = stable_pair)
    ),
    paste0(
      "Pair assignments: `shared` in `block_pairings[[\"stable\"]]`, ",
      "`block_pairings[[\"follow_up\"]]`"
    ),
    fixed = TRUE
  )
})

test_that("fitted-row validation protects the exchangeable coding", {
  valid <- data.frame(
    IDIFF = c(-1, 1, 0, 0),
    SAMESEX = c(1, 1, 0, 0)
  )
  expect_no_error(validate_exchangeable_coding(valid, "IDIFF", "SAMESEX"))
  logical_support <- valid
  logical_support$SAMESEX <- as.logical(logical_support$SAMESEX)
  expect_no_error(
    validate_exchangeable_coding(logical_support, "IDIFF", "SAMESEX")
  )
  expect_no_error(validate_exchangeable_coding(
    data.frame(IDIFF = c(-1, 1)),
    "IDIFF",
    "1"
  ))
  short_member_contrast <- paste0(
    dyad_retained_prefix,
    "member_contrast_arbitrary"
  )
  short_coding_data <- data.frame(value = rep(c(-1, 1), 4L))
  names(short_coding_data) <- short_member_contrast
  expect_no_warning(validate_exchangeable_coding(
    short_coding_data,
    short_member_contrast,
    "1",
    group_ids = rep(1:2, each = 4L),
    group_name = "coupleID"
  ))
  person_coding_data <- data.frame(value = c(-1, -1, 1, 1))
  names(person_coding_data) <- short_member_contrast
  expect_error(
    validate_exchangeable_coding(
      person_coding_data,
      short_member_contrast,
      "1",
      group_ids = c(1, 1, 2, 2),
      group_name = "personID"
    ),
    "No supported fitted `personID` group contains both -1 and +1",
    fixed = TRUE
  )
  expect_warning(
    validate_exchangeable_coding(
      data.frame(other = 1:2),
      "IDIFF",
      "1"
    ),
    "Before interpreting the result, verify",
    fixed = TRUE
  )
  expect_warning(
    validate_exchangeable_coding(
      data.frame(IDIFF = c(-1, 1)),
      "IDIFF",
      "SAMESEX"
    ),
    "coded 1 exactly where `abs(IDIFF) == 1` and 0 elsewhere",
    fixed = TRUE
  )

  character_coding <- valid
  character_coding$IDIFF <- as.character(character_coding$IDIFF)
  expect_error(
    validate_exchangeable_coding(character_coding, "IDIFF", "SAMESEX"),
    "`IDIFF` and `SAMESEX` must be complete numeric columns",
    fixed = TRUE
  )
  expect_warning(
    expect_error(
      validate_exchangeable_coding(
        character_coding["IDIFF"],
        "IDIFF",
        "SAMESEX"
      ),
      "must be complete numeric columns",
      fixed = TRUE
    ),
    "support could not be checked",
    fixed = TRUE
  )

  wrong_scale <- valid
  wrong_scale$IDIFF <- wrong_scale$IDIFF / 2
  expect_error(
    validate_exchangeable_coding(wrong_scale, "IDIFF", "SAMESEX"),
    "must use -1/+1 coding",
    fixed = TRUE
  )

  wrong_support <- valid
  wrong_support$IDIFF[[3L]] <- 1
  expect_error(
    validate_exchangeable_coding(wrong_support, "IDIFF", "SAMESEX"),
    "must use -1/+1 coding",
    fixed = TRUE
  )

  one_sign <- data.frame(IDIFF = c(1, 1), SAMESEX = c(1, 1))
  expect_error(
    validate_exchangeable_coding(one_sign, "IDIFF", "SAMESEX"),
    "whether fitted-row filtering removed one member position",
    fixed = TRUE
  )

  expect_warning(
    validate_exchangeable_coding(
      data.frame(IDIFF = rep(c(-1, 1), 4L)),
      "IDIFF",
      "1",
      group_ids = rep(1:2, each = 4L),
      group_name = "coupleID"
    ),
    "stable member-position coding for custom `IDIFF` could not be verified",
    fixed = TRUE
  )
  expect_warning(
    validate_exchangeable_coding(
      data.frame(IDIFF = c(-1, 1, -1, -1)),
      "IDIFF",
      "1",
      group_ids = rep(1:2, each = 2L),
      group_name = "coupleID"
    ),
    "1 of 2 supported fitted `coupleID` groups do not contain both -1 and +1",
    fixed = TRUE
  )
  expect_warning(
    validate_exchangeable_coding(
      data.frame(IDIFF = c(-1, 1)),
      "IDIFF",
      "1",
      group_name = "coupleID"
    ),
    "Grouping IDs for `coupleID` were unavailable",
    fixed = TRUE
  )

  marker <- ".member_contrast_same_sex_arbitrary"
  generic_blocks <- list(
    rescov_test_block("coupleID", "(Intercept)", "generic shared"),
    rescov_test_block("coupleID", marker, "difference")
  )
  model_frame <- data.frame(value = seq_len(4))
  model_frame[[marker]] <- c(-1, 1, 0, 0)
  expect_error(
    match_exchangeable_residual_blocks(generic_blocks, model_frame),
    "must use -1/+1 coding",
    fixed = TRUE
  )
})

test_that("levels with at most two fitted observations are flagged", {
  blocks <- list(
    rescov_test_block("coupleID", "(Intercept)", "(1 | coupleID)"),
    rescov_test_block("coupleID", "IDIFF", "(0 + IDIFF | coupleID)")
  )
  pair <- build_exchangeable_pair(blocks, 1L, 2L, "IDIFF", "1")
  extracted <- list(
    backend = "glmmTMB",
    blocks = blocks,
    group_ids = list(coupleID = rep(seq_len(3L), each = 2L))
  )

  expect_true(may_be_exchangeable_residual_level(extracted, pair))

  extracted$group_ids$coupleID <- c(1, 1, 1, 1, 2, 2)
  expect_false(may_be_exchangeable_residual_level(extracted, pair))

  # The detector deliberately uses only row counts. It therefore remains
  # cautious even when all fitted grouping units are singletons.
  extracted$group_ids$coupleID <- seq_len(6L)
  expect_true(may_be_exchangeable_residual_level(extracted, pair))

  extracted$group_ids <- list()
  expect_false(may_be_exchangeable_residual_level(extracted, pair))
})

test_that("residual-level warnings explain brms, omissions, and slopes", {
  shared_covariance <- array(
    diag(c(1, 0.25)),
    dim = c(1L, 2L, 2L),
    dimnames = list(NULL, c("(Intercept)", "time"), c("(Intercept)", "time"))
  )
  difference_covariance <- array(
    0.5,
    dim = c(1L, 1L, 1L),
    dimnames = list(NULL, "IDIFF", "IDIFF")
  )
  blocks <- list(
    rescov_test_block(
      "coupleID",
      c("(Intercept)", "time"),
      "(1 + time | coupleID)",
      shared_covariance
    ),
    rescov_test_block(
      "coupleID",
      "IDIFF",
      "(0 + IDIFF | coupleID)",
      difference_covariance
    )
  )
  pair <- build_exchangeable_pair(blocks, 1L, 2L, "IDIFF", "1")
  extracted <- list(
    backend = "brms",
    blocks = blocks,
    group_ids = list(coupleID = rep(seq_len(3L), each = 2L))
  )

  warning_text <- NULL
  withCallingHandlers(
    warn_about_exchangeable_residual_level(
      extracted,
      list(same_occasion = pair)
    ),
    warning = function(warning) {
      warning_text <<- conditionMessage(warning)
      invokeRestart("muffleWarning")
    }
  )

  expect_match(
    warning_text,
    "Review possible residual-level structure:",
    fixed = TRUE
  )
  expect_match(
    warning_text,
    "Pair `same_occasion` for `IDIFF` (group `coupleID`)",
    fixed = TRUE
  )
  expect_match(warning_text, "may be residual-level", fixed = TRUE)
  expect_match(
    warning_text,
    "reason: at most two fitted rows per group",
    fixed = TRUE
  )
  expect_match(warning_text, "For `brms`", fixed = TRUE)
  expect_match(warning_text, "unstr(time = member_position", fixed = TRUE)
  expect_match(
    warning_text,
    "See `?recover_exchangeable_covariance`",
    fixed = TRUE
  )
  expect_match(
    warning_text,
    "Terms absent from the difference block: `time`",
    fixed = TRUE
  )
  expect_match(warning_text, "fitted model fixes", fixed = TRUE)
  expect_match(warning_text, "correlation +1", fixed = TRUE)
  expect_match(warning_text, "undefined at zero", fixed = TRUE)
  expect_match(
    warning_text,
    "Non-intercept terms: `time`",
    fixed = TRUE
  )

  warnings <- testthat::capture_warnings(
    warn_about_exchangeable_residual_level(
      extracted,
      list(first = pair, second = pair)
    )
  )
  expect_length(warnings, 2L)
  expect_match(warnings[[1L]], "Pair `first`", fixed = TRUE)
  expect_false(grepl("Pair `second`", warnings[[1L]], fixed = TRUE))
  expect_match(warnings[[2L]], "Pair `second`", fixed = TRUE)
  expect_false(grepl("Pair `first`", warnings[[2L]], fixed = TRUE))

  mixed_pair <- pair
  mixed_pair$shared_indicator <- ".is_same_sex"
  expect_warning(
    warn_about_exchangeable_residual_level(
      extracted,
      list(mixed_pair)
    ),
    "separate composition-specific `unstr()` structures",
    fixed = TRUE
  )

  # A wholly omitted difference block is still flagged even though no idiff
  # term or member-position column exists in the fitted model.
  shared_only <- build_exchangeable_pair(
    blocks,
    1L,
    NA_integer_,
    NULL,
    "1"
  )
  extracted$backend <- "glmmTMB"
  extracted$group_ids$coupleID <- rep(seq_len(3L), each = 2L)
  expect_warning(
    warn_about_exchangeable_residual_level(
      extracted,
      list(shared_only = shared_only)
    ),
    "Terms absent from the difference block: `(Intercept)`, `time`",
    fixed = TRUE
  )

  # Omissions from the shared block receive the corresponding -1 implication.
  difference_only_blocks <- list(
    rescov_test_block(
      "coupleID",
      "(Intercept)",
      "(1 | coupleID)"
    ),
    rescov_test_block(
      "coupleID",
      c("IDIFF", "IDIFF:time"),
      "(0 + IDIFF + IDIFF:time | coupleID)"
    )
  )
  missing_shared_slope <- build_exchangeable_pair(
    difference_only_blocks,
    1L,
    2L,
    "IDIFF",
    "1"
  )
  extracted$blocks <- difference_only_blocks
  expect_warning(
    warn_about_exchangeable_residual_level(
      extracted,
      list(missing_shared_slope)
    ),
    "Terms absent from the shared block: `time`.*correlation -1",
    fixed = FALSE
  )
})

test_that("the public function returns member-level glmmTMB matrices", {
  skip_if_not_installed("glmmTMB")

  marker <- ".member_contrast_assumed_exchangeable_arbitrary"
  data <- expand.grid(member = c(-1, 1), coupleID = seq_len(30))
  data$coupleID <- factor(data$coupleID)
  data[[marker]] <- data$member
  set.seed(456)
  data$outcome <- stats::rnorm(nrow(data))

  model <- suppressWarnings(glmmTMB::glmmTMB(
    outcome ~ 1 + (1 | coupleID) +
      (0 + .member_contrast_assumed_exchangeable_arbitrary || coupleID),
    dispformula = ~0,
    data = data
  ))
  result <- recover_exchangeable_covariance(model)
  extracted <- glmmTMB_extract_exchangeable_residual_blocks(model)
  shared_variance <- extracted$blocks[[1L]]$covariance[1L, 1L, 1L]
  difference_variance <- extracted$blocks[[2L]]$covariance[1L, 1L, 1L]
  member_variance <- shared_variance + difference_variance
  member_covariance <- shared_variance - difference_variance

  expect_s3_class(result, "exchangeable_covariance")
  expect_length(result, 1L)
  expect_named(
    result[[1L]],
    c("shared_term", "difference_term", "varcov", "sdcor")
  )
  expect_true(is.matrix(result[[1L]]$varcov))
  expect_true(is.matrix(result[[1L]]$sdcor))
  expect_equal(
    unname(result[[1L]]$varcov),
    matrix(
      c(
        member_variance, member_covariance,
        member_covariance, member_variance
      ),
      2L
    )
  )
  expect_equal(
    unname(diag(result[[1L]]$sdcor)),
    rep(sqrt(member_variance), 2L)
  )
  expect_equal(
    result[[1L]]$sdcor[1L, 2L],
    member_covariance / member_variance
  )
  expect_named(result, "pair_1")
  expect_identical(result[[1L]]$shared_term, extracted$blocks[[1L]]$term)
  expect_identical(result[[1L]]$difference_term, extracted$blocks[[2L]]$term)
})

test_that("the public function returns fitted link-scale covariance for Poisson models", {
  skip_if_not_installed("glmmTMB")

  marker <- ".member_contrast_assumed_exchangeable_arbitrary"
  n_dyads <- 300L
  data <- expand.grid(member = c(-1, 1), coupleID = seq_len(n_dyads))
  data$coupleID <- factor(data$coupleID)
  data[[marker]] <- data$member

  set.seed(20260811)
  shared_effect <- rep(stats::rnorm(n_dyads, sd = 0.45), each = 2L)
  difference_effect <- rep(stats::rnorm(n_dyads, sd = 0.25), each = 2L)
  linear_predictor <- 1.4 + shared_effect + data[[marker]] * difference_effect
  data$count <- stats::rpois(nrow(data), lambda = exp(linear_predictor))

  model <- glmmTMB::glmmTMB(
    count ~ 1 + (1 | coupleID) +
      (0 + .member_contrast_assumed_exchangeable_arbitrary || coupleID),
    family = poisson(),
    data = data
  )

  expect_identical(model$fit$convergence, 0L)
  expect_true(model$sdr$pdHess)

  result <- recover_exchangeable_covariance(model)
  extracted <- glmmTMB_extract_exchangeable_residual_blocks(model)
  shared_variance <- extracted$blocks[[1L]]$covariance[1L, 1L, 1L]
  difference_variance <- extracted$blocks[[2L]]$covariance[1L, 1L, 1L]
  expected_varcov <- matrix(
    c(
      shared_variance + difference_variance,
      shared_variance - difference_variance,
      shared_variance - difference_variance,
      shared_variance + difference_variance
    ),
    2L
  )

  expect_s3_class(result, "exchangeable_covariance")
  expect_equal(unname(result[[1L]]$varcov), expected_varcov)
})

test_that("shared-only recovery checks a named shared indicator", {
  skip_if_not_installed("glmmTMB")

  model_data <- expand.grid(member = c(-1, 1), occasion = 1:4, dyad = 1:50)
  model_data$shared <- as.numeric(model_data$dyad <= 25L)
  set.seed(472)
  shared_effect <- stats::rnorm(50L, sd = 2)
  model_data$y <- 2 + model_data$shared * shared_effect[model_data$dyad] +
    stats::rnorm(nrow(model_data), sd = 0.3)
  shared_only <- list(
    shared_block = "(0 + shared | dyad)",
    difference_block = NULL,
    shared_indicator = "shared"
  )

  model <- glmmTMB::glmmTMB(y ~ 1 + (0 + shared | dyad), data = model_data)
  expect_warning(
    result <- recover_exchangeable_covariance(
      model,
      block_pairings = shared_only
    ),
    "Use `NULL` only if that block was omitted",
    fixed = TRUE
  )
  expect_s3_class(result, "exchangeable_covariance")

  model_data$shared <- model_data$shared / 2
  model <- glmmTMB::glmmTMB(y ~ 1 + (0 + shared | dyad), data = model_data)
  expect_error(
    recover_exchangeable_covariance(model, block_pairings = shared_only),
    "`block_pairings`: `shared` must be 1 on the rows of this composition and 0 on all other rows.",
    fixed = TRUE
  )
})

test_that("fitted covariance recovery allows some one-sided groups", {
  skip_if_not_installed("glmmTMB")

  model_data <- data.frame(
    coupleID = factor(rep(seq_len(40L), each = 2L)),
    IDIFF = rep(c(-1, 1), 40L)
  )
  couple_index <- as.integer(model_data$coupleID)
  set.seed(902)
  shared_effect <- stats::rnorm(40L, sd = 1)
  difference_effect <- stats::rnorm(40L, sd = 0.5)
  model_data$outcome <- shared_effect[couple_index] +
    model_data$IDIFF * difference_effect[couple_index]
  model_data$outcome[couple_index <= 3L & model_data$IDIFF == 1] <- NA_real_

  model <- suppressWarnings(glmmTMB::glmmTMB(
    outcome ~ 1 +
      us(1 | coupleID) +
      us(0 + IDIFF | coupleID),
    dispformula = ~0,
    data = model_data
  ))

  expect_identical(model$fit$convergence, 0L)
  expect_true(model$sdr$pdHess)
  expect_equal(nrow(model$frame), 77L)
  expect_warning(
    result <- recover_exchangeable_covariance(
      model,
      block_pairings = list(
        dyad = list(
          shared_block = "us(1 | coupleID)",
          difference_block = "us(0 + IDIFF | coupleID)",
          difference_indicator = "IDIFF"
        )
      )
    ),
    "3 of 40 supported fitted `coupleID` groups do not contain both -1 and +1",
    fixed = TRUE
  )

  expect_s3_class(result, "exchangeable_covariance")
  expect_true(all(is.finite(result[["dyad"]]$varcov)))
})

test_that("automatic matching checks partner positions within fitted groups", {
  skip_if_not_installed("glmmTMB")

  marker <- ".member_contrast_arbitrary"
  model_data <- expand.grid(
    occasion = 1:3,
    member = c(-1, 1),
    coupleID = seq_len(40L)
  )
  model_data[[marker]] <- model_data$member
  model_data$personID <- paste(model_data$coupleID, model_data$member)
  set.seed(29)
  model_data$outcome <- stats::rnorm(40L)[model_data$coupleID] +
    model_data$member * stats::rnorm(40L, sd = 0.5)[model_data$coupleID] +
    stats::rnorm(nrow(model_data), sd = 0.5)

  fit_grouped_by <- function(group, rows = TRUE) {
    suppressWarnings(glmmTMB::glmmTMB(
      stats::as.formula(paste0(
        "outcome ~ 1 + (1 | ", group, ") + (0 + ", marker, " | ", group, ")"
      )),
      data = model_data[rows, ]
    ))
  }

  expect_no_warning(recover_exchangeable_covariance(fit_grouped_by("coupleID")))
  expect_error(
    recover_exchangeable_covariance(fit_grouped_by("personID")),
    "No supported fitted `personID` group contains both -1 and +1",
    fixed = TRUE
  )
  one_sided <- model_data$coupleID <= 3L & model_data$member == 1
  expect_no_warning(
    recover_exchangeable_covariance(fit_grouped_by("coupleID", !one_sided))
  )
})

test_that("the public function can retain brms draws and labels omitted blocks", {
  data <- data.frame(outcome = 1:4, IDIFF = rep(c(-1, 1), 2L))
  model <- stats::lm(outcome ~ IDIFF, data = data)
  shared_draws <- array(c(1.2, 0), dim = c(2, 1, 1))
  difference_draws <- array(c(0.4, 0.8), dim = c(2, 1, 1))
  extracted <- list(
    backend = "brms",
    blocks = list(
      rescov_test_block(
        "coupleID",
        "(Intercept)",
        "(1 | coupleID)",
        shared_draws
      ),
      rescov_test_block(
        "familyID",
        "IDIFF",
        "(0 + IDIFF | familyID)",
        difference_draws
      )
    )
  )

  expect_warning(
    result <- testthat::with_mocked_bindings(
      recover_exchangeable_covariance(
        model,
        posterior = "draws",
        block_pairings = list(
          shared_only = list(
            shared_block = "(1 | coupleID)",
            difference_block = NULL,
            difference_indicator = "IDIFF"
          ),
          difference_only = list(
            shared_block = NULL,
            difference_block = "(0 + IDIFF | familyID)",
            difference_indicator = "IDIFF"
          )
        )
      ),
      extract_exchangeable_residual_blocks = function(model) extracted,
      .package = "dyadMLM"
    ),
    "standard deviation is zero",
    fixed = TRUE
  )

  expect_s3_class(result, "exchangeable_covariance")
  expect_length(result, 2L)
  expect_equal(dim(result[[1L]]$varcov), c(2L, 2L, 2L))
  expect_equal(dim(result[[1L]]$sdcor), c(2L, 2L, 2L))
  expect_equal(
    unname(result[[1L]]$varcov[1L, , ]),
    matrix(1.2, 2L, 2L)
  )
  expect_equal(result[[1L]]$sdcor[1L, 1L, 2L], 1)
  expect_true(is.na(result[[1L]]$sdcor[2L, 1L, 2L]))
  expect_equal(
    unname(result[[2L]]$varcov[1L, , ]),
    matrix(c(0.4, -0.4, -0.4, 0.4), 2L)
  )
  expect_equal(result[[2L]]$sdcor[1L, 1L, 2L], -1)
  expect_named(result, c("shared_only", "difference_only"))
  expect_null(result[[1L]]$difference_term)
  expect_null(result[[2L]]$shared_term)
})

test_that("brms results return point-estimate matrices by default", {
  data <- data.frame(outcome = 1:4, IDIFF = rep(c(-1, 1), 2L))
  model <- stats::lm(outcome ~ IDIFF, data = data)
  shared_draws <- array(c(1, 4, 9), dim = c(3, 1, 1))
  extracted <- list(
    backend = "brms",
    blocks = list(rescov_test_block(
      "coupleID",
      "(Intercept)",
      "(1 | coupleID)",
      shared_draws
    ))
  )
  pairing <- list(
    shared_block = "(1 | coupleID)",
    difference_block = NULL,
    difference_indicator = "IDIFF"
  )

  result <- testthat::with_mocked_bindings(
    recover_exchangeable_covariance(
      model,
      block_pairings = pairing
    ),
    extract_exchangeable_residual_blocks = function(model) extracted,
    .package = "dyadMLM"
  )
  median_result <- testthat::with_mocked_bindings(
    recover_exchangeable_covariance(
      model,
      block_pairings = pairing,
      posterior = "median"
    ),
    extract_exchangeable_residual_blocks = function(model) extracted,
    .package = "dyadMLM"
  )

  expect_true(is.matrix(result[[1L]]$varcov))
  expect_true(is.matrix(result[[1L]]$sdcor))
  expect_equal(
    unname(result[[1L]]$varcov),
    matrix(mean(c(1, 4, 9)), 2L, 2L)
  )
  expect_equal(
    unname(result[[1L]]$sdcor),
    matrix(c(2, 1, 1, 2), 2L)
  )
  expect_equal(
    unname(median_result[[1L]]$varcov),
    matrix(4, 2L, 2L)
  )
  expect_equal(
    unname(median_result[[1L]]$sdcor),
    matrix(c(2, 1, 1, 2), 2L)
  )
})

test_that("exchangeable covariance results print structured pairings", {
  varcov <- matrix(
    c(1.5, 0.9, 0.9, 1.5),
    2L,
    dimnames = rep(
      list(c("member_1: (Intercept)", "member_2: (Intercept)")),
      2L
    )
  )
  sdcor <- matrix(
    c(sqrt(1.5), 0.6, 0.6, sqrt(1.5)),
    2L,
    dimnames = dimnames(varcov)
  )
  result <- structure(
    list(dyad = list(
      shared_term = "us(1 | coupleID)",
      difference_term = "us(0 + IDIFF | coupleID)",
      varcov = varcov,
      sdcor = sdcor
    )),
    class = c("exchangeable_covariance", "list")
  )

  printed <- capture.output(returned <- print(result))
  expect_identical(returned, result)
  expect_true(any(grepl(
    "Recovered exchangeable member-level covariance",
    printed,
    fixed = TRUE
  )))
  expect_true(any(grepl("Shared:     us(1 | coupleID)", printed, fixed = TRUE)))
  expect_true(any(grepl(
    "Difference: us(0 + IDIFF | coupleID)",
    printed,
    fixed = TRUE
  )))
  expect_equal(sum(grepl("us(1 | coupleID)", printed, fixed = TRUE)), 1L)
  expect_true(any(grepl("Variance-covariance:", printed, fixed = TRUE)))
  expect_true(any(grepl(
    "Standard deviations and correlations:",
    printed,
    fixed = TRUE
  )))
  expect_false(any(grepl("Coefficient key", printed, fixed = TRUE)))
  expect_true(any(grepl("1 member1: (Intercept)", printed, fixed = TRUE)))
  expect_true(any(grepl("2 member2: (Intercept)", printed, fixed = TRUE)))
  expect_true(any(grepl("1.225", printed, fixed = TRUE)))

  varcov_only <- capture.output(print(result, "varcov"))
  expect_true(any(grepl("Variance-covariance:", varcov_only, fixed = TRUE)))
  expect_false(any(grepl(
    "Standard deviations and correlations:",
    varcov_only,
    fixed = TRUE
  )))

  sdcor_only <- capture.output(print(result, representation = "sdcor"))
  expect_false(any(grepl("Variance-covariance:", sdcor_only, fixed = TRUE)))
  expect_true(any(grepl(
    "Standard deviations and correlations:",
    sdcor_only,
    fixed = TRUE
  )))

  expect_error(
    print(result, what = "varcov"),
    "`what` has been renamed to `representation`",
    fixed = TRUE
  )

  multiple <- structure(
    c(unclass(result), unclass(result)),
    class = c("exchangeable_covariance", "list")
  )
  names(multiple) <- c(
    "dyad",
    "family"
  )
  multiple[[2L]]$shared_term <- "us(1 | familyID)"
  multiple[[2L]]$difference_term <- "us(0 + IDIFF | familyID)"
  multiple_printed <- capture.output(print(multiple, representation = "varcov"))
  expect_true(any(grepl(
    "Recovered exchangeable member-level covariances (2 block pairs)",
    multiple_printed,
    fixed = TRUE
  )))
  expect_equal(sum(multiple_printed == "Pair `dyad`"), 1L)
  expect_equal(sum(multiple_printed == "Pair `family`"), 1L)
})

test_that("printing brms results does not dump posterior draw arrays", {
  draws <- array(
    1,
    dim = c(5, 2, 2),
    dimnames = list(NULL, c("member_1", "member_2"), c("member_1", "member_2"))
  )
  result <- structure(
    list(
      pair_1 = list(
        shared_term = "(1 | coupleID)",
        difference_term = NULL,
        varcov = draws,
        sdcor = draws
      )
    ),
    class = c("exchangeable_covariance", "list")
  )

  printed <- capture.output(print(result, representation = "sdcor"))
  expect_true(any(grepl(
    "5 posterior draws x 2 coefficients x 2 coefficients",
    printed,
    fixed = TRUE
  )))
  expect_true(any(grepl(
    "Extract with `x[[1]]$sdcor`",
    printed,
    fixed = TRUE
  )))
  expect_true(any(grepl("Difference: <omitted>", printed, fixed = TRUE)))
})

Try the dyadMLM package in your browser

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

dyadMLM documentation built on Oct. 28, 2026, 1:06 a.m.