tests/testthat/test-reconc_t.R

# Shared fixtures --------------------------------------------------------------

# Simple 1-upper, 2-bottom hierarchy: u = b1 + b2
.A_simple <- matrix(c(1, 1), nrow = 1)

# Coherent base forecasts: upper = sum of bottoms
.mu_coh <- c(10, 4, 6) # u=10, b1=4, b2=6  =>  delta = A%*%b - u = 0

# Incoherent base forecasts: upper != sum of bottoms
.mu_inc <- c(11, 4, 6) # u=11, b1=4, b2=6  =>  delta = -1

# Posterior parameters
.nu_post <- 50
.Psi_post <- diag(c(3, 1, 1.5)) * .nu_post  # scale ~ nu * sigma^2 per series


# 1. Output structure ----------------------------------------------------------

test_that("reconc_t output contains expected fields (bottom only)", {
  res <- reconc_t(.A_simple, .mu_coh,
    posterior = list(nu = .nu_post, Psi = .Psi_post)
  )

  expect_named(res, c("bottom_rec_mean", "bottom_rec_scale_matrix", "bottom_rec_df"),
    ignore.order = TRUE
  )
  expect_length(res$bottom_rec_mean, ncol(.A_simple))
  expect_equal(dim(res$bottom_rec_scale_matrix), c(ncol(.A_simple), ncol(.A_simple)))
  expect_length(res$bottom_rec_df, 1)
})


# 2. Degrees of freedom formula ------------------------------------------------

test_that("bottom_rec_df equals nu_post - n_bottom + 1", {
  res <- reconc_t(.A_simple, .mu_coh,
    posterior = list(nu = .nu_post, Psi = .Psi_post)
  )
  expect_equal(res$bottom_rec_df, .nu_post - ncol(.A_simple) + 1)
})


# 3. Coherent forecasts: reconciled mean unchanged -----------------------------

test_that("reconciled mean is unchanged when base forecasts are already coherent", {
  res <- reconc_t(.A_simple, .mu_coh,
    posterior = list(nu = .nu_post, Psi = .Psi_post)
  )
  expect_equal(res$bottom_rec_mean, .mu_coh[(nrow(.A_simple) + 1):length(.mu_coh)],
    tolerance = 1e-10
  )
})


# 4. return_upper: upper_rec_mean == A %*% bottom_rec_mean ----------------------

test_that("return_upper=TRUE returns correct upper fields consistent with bottom", {
  res <- reconc_t(.A_simple, .mu_inc,
    posterior = list(nu = .nu_post, Psi = .Psi_post),
    return_upper = TRUE
  )

  expect_true(all(c("upper_rec_mean", "upper_rec_scale_matrix", "upper_rec_df") %in% names(res)))

  # upper_rec_mean must equal A %*% bottom_rec_mean (closure property)
  expect_equal(
    as.vector(res$upper_rec_mean),
    as.vector(.A_simple %*% res$bottom_rec_mean),
    tolerance = 1e-10
  )

  # upper_rec_scale_matrix must equal A %*% bottom_rec_scale_matrix %*% t(A)
  expect_equal(
    res$upper_rec_scale_matrix,
    .A_simple %*% res$bottom_rec_scale_matrix %*% t(.A_simple),
    tolerance = 1e-10
  )

  # degrees of freedom are the same for upper and bottom
  expect_equal(res$upper_rec_df, res$bottom_rec_df)
})

test_that("return_upper=FALSE (default) does not return upper fields", {
  res <- reconc_t(.A_simple, .mu_inc,
    posterior = list(nu = .nu_post, Psi = .Psi_post)
  )
  expect_false("upper_rec_mean" %in% names(res))
  expect_false("upper_rec_scale_matrix" %in% names(res))
})


# 5. Scale matrix is symmetric and positive-definite ---------------------------

test_that("bottom_rec_scale_matrix is symmetric and positive definite", {
  res <- reconc_t(.A_simple, .mu_inc,
    posterior = list(nu = .nu_post, Psi = .Psi_post)
  )
  S <- res$bottom_rec_scale_matrix

  # Symmetry
  expect_equal(S, t(S), tolerance = 1e-12)

  # Positive definiteness: all eigenvalues > 0
  eigs <- eigen(S, symmetric = TRUE, only.values = TRUE)$values
  expect_true(all(eigs > 0))
})


# 6. Incoherence: reconciled mean is coherent ----------------------------------
# After reconciliation, A %*% bottom_rec_mean always equals upper_rec_mean

test_that("reconciled forecasts satisfy the hierarchical constraint", {
  res <- reconc_t(.A_simple, .mu_inc,
    posterior = list(nu = .nu_post, Psi = .Psi_post),
    return_upper = TRUE
  )
  expect_equal(
    as.vector(.A_simple %*% res$bottom_rec_mean),
    as.vector(res$upper_rec_mean),
    tolerance = 1e-10
  )
})


# 7. Input via residuals (no error) --------------------------------------------

test_that("reconc_t runs without error when residuals and prior are provided", {
  set.seed(42)
  n <- 3
  L <- 30  # training length

  A <- .A_simple
  mu <- .mu_inc

  # Simulate residuals
  res_mat <- matrix(rnorm(L * n), nrow = L, ncol = n)

  # Simple diagonal prior
  nu_prior <- 10
  Psi_prior <- diag(n) * (nu_prior - n - 1)

  expect_no_error(
    reconc_t(A, mu, prior = list(nu = nu_prior, Psi = Psi_prior), residuals = res_mat)
  )
})


# 8. Posterior input reproduces prior+residuals path -------------------------

test_that("direct posterior input gives same result as prior+residuals path", {
  set.seed(7)
  n <- 3
  L <- 20

  A <- .A_simple
  mu <- .mu_inc
  res_mat <- matrix(rnorm(L * n), nrow = L, ncol = n)

  nu_prior <- 15
  Psi_prior <- diag(n) * (nu_prior - n - 1)

  # Path A: via prior + residuals
  res_via_prior <- reconc_t(A, mu,
    prior = list(nu = nu_prior, Psi = Psi_prior),
    residuals = res_mat
  )

  # Manual posterior computation (mirrors internal logic)
  Samp_cov <- crossprod(res_mat) / L
  lam <- .L_SHRINK_RECONC_T
  Samp_cov_shr <- (1 - lam) * Samp_cov + lam * diag(diag(Samp_cov))
  nu_post_man <- nu_prior + L
  Psi_post_man <- Psi_prior + L * Samp_cov_shr

  # Path B: directly provide posterior
  res_via_post <- reconc_t(A, mu,
    posterior = list(nu = nu_post_man, Psi = Psi_post_man)
  )

  expect_equal(res_via_prior$bottom_rec_mean, res_via_post$bottom_rec_mean, tolerance = 1e-10)
  expect_equal(res_via_prior$bottom_rec_scale_matrix, res_via_post$bottom_rec_scale_matrix,
    tolerance = 1e-10
  )
  expect_equal(res_via_prior$bottom_rec_df, res_via_post$bottom_rec_df)
})


# 9. Unknown argument triggers a warning -------------------------------------

test_that("unknown arguments in ... trigger a warning", {
  expect_warning(
    reconc_t(.A_simple, .mu_coh,
      posterior = list(nu = .nu_post, Psi = .Psi_post),
      unknown_arg = 99
    ),
    regexp = "unknown_arg"
  )
})


# 10. Standard usage: y_train + residuals (CASE 2b) ---------------------------
# This path runs .compute_naive_cov + multi_log_score_optimization to estimate
# nu_prior and Psi_prior, then updates to the posterior and reconciles.

# Shared fixture for CASE 2b tests (mts y_train with frequency=1, no seasonality)
set.seed(123)
.n_series <- 3L   # 1 upper + 2 bottom
.L_train  <- 50L
.A_2b     <- matrix(c(1, 1), nrow = 1)
.y_mat    <- ts(matrix(rnorm(.L_train * .n_series, mean = 10), nrow = .L_train, ncol = .n_series),
                frequency = 4)  # mts object with frequency=4, but no actual seasonality
.res_mat  <- matrix(rnorm(.L_train * .n_series), nrow = .L_train, ncol = .n_series)
.mu_2b    <- c(11, 4, 6)  # incoherent: 11 != 4 + 6 = 10

test_that("reconc_t runs without error with y_train + residuals (standard usage)", {
  expect_no_error(
    reconc_t(.A_2b, .mu_2b, y_train = .y_mat, residuals = .res_mat)
  )
})

test_that("reconc_t output structure is correct with y_train + residuals", {
  result <- reconc_t(.A_2b, .mu_2b, y_train = .y_mat, residuals = .res_mat)

  expect_named(result, c("bottom_rec_mean", "bottom_rec_scale_matrix", "bottom_rec_df"),
    ignore.order = TRUE
  )
  expect_length(result$bottom_rec_mean, ncol(.A_2b))
  expect_equal(dim(result$bottom_rec_scale_matrix), c(ncol(.A_2b), ncol(.A_2b)))
  expect_length(result$bottom_rec_df, 1)
  expect_true(result$bottom_rec_df > 0)
})

test_that("reconciled forecasts satisfy hierarchical constraint with y_train + residuals", {
  result <- reconc_t(.A_2b, .mu_2b, y_train = .y_mat, residuals = .res_mat,
    return_upper = TRUE
  )

  expect_equal(
    as.vector(.A_2b %*% result$bottom_rec_mean),
    as.vector(result$upper_rec_mean),
    tolerance = 1e-10
  )
})

test_that("bottom_rec_df equals posterior_nu - n_bottom + 1 with y_train + residuals", {
  result <- reconc_t(.A_2b, .mu_2b, y_train = .y_mat, residuals = .res_mat,
    return_parameters = TRUE
  )

  expect_equal(result$bottom_rec_df, result$posterior_nu - ncol(.A_2b) + 1)
})

test_that("posterior_nu equals optimized nu_prior + L with y_train + residuals", {
  result <- reconc_t(.A_2b, .mu_2b, y_train = .y_mat, residuals = .res_mat,
    return_parameters = TRUE
  )

  # posterior_nu = nu_prior + L; since nu_prior >= n + 2, posterior_nu > L
  expect_true(result$posterior_nu > .L_train)
  # Also must be valid for the t-distribution (> n_series - 1)
  expect_true(result$posterior_nu > .n_series - 1)
})

test_that("prior and posterior parameters are returned with y_train + residuals (return_parameters=TRUE)", {
  result <- reconc_t(.A_2b, .mu_2b, y_train = .y_mat, residuals = .res_mat,
    return_parameters = TRUE
  )

  expect_true(all(c("prior_nu", "prior_Psi", "posterior_nu", "posterior_Psi") %in% names(result)))
  expect_true(is.numeric(result$prior_nu))
  expect_true(is.matrix(result$prior_Psi))
  expect_equal(dim(result$prior_Psi), c(.n_series, .n_series))
  expect_true(is.numeric(result$posterior_nu))
  expect_true(is.matrix(result$posterior_Psi))
  expect_equal(dim(result$posterior_Psi), c(.n_series, .n_series))
})

test_that("bottom_rec_scale_matrix is symmetric and positive definite with y_train + residuals", {
  result <- reconc_t(.A_2b, .mu_2b, y_train = .y_mat, residuals = .res_mat)

  S <- result$bottom_rec_scale_matrix
  expect_equal(S, t(S), tolerance = 1e-12)
  eigs <- eigen(S, symmetric = TRUE, only.values = TRUE)$values
  expect_true(all(eigs > 0))
})

test_that("return_upper=TRUE works correctly with y_train + residuals", {
  result <- reconc_t(.A_2b, .mu_2b, y_train = .y_mat, residuals = .res_mat,
    return_upper = TRUE
  )

  expect_true(all(c("upper_rec_mean", "upper_rec_scale_matrix", "upper_rec_df") %in% names(result)))
  expect_equal(
    result$upper_rec_scale_matrix,
    .A_2b %*% result$bottom_rec_scale_matrix %*% t(.A_2b),
    tolerance = 1e-10
  )
  expect_equal(result$upper_rec_df, result$bottom_rec_df)
})

test_that("reconc_t standard usage works with explicit freq argument", {
  set.seed(42)
  freq <- 4L
  L <- 40L
  y_seas <- matrix(rnorm(L * .n_series, mean = 10), nrow = L, ncol = .n_series)
  res_seas <- matrix(rnorm(L * .n_series), nrow = L, ncol = .n_series)

  expect_no_error(
    reconc_t(.A_2b, .mu_2b, y_train = y_seas, residuals = res_seas, freq = freq)
  )
})

test_that("reconc_t standard usage works when y_train is an mts object", {
  set.seed(42)
  freq <- 4L
  L <- 40L
  y_mts <- ts(matrix(rnorm(L * .n_series, mean = 10), nrow = L, ncol = .n_series),
    frequency = freq
  )
  res_seas <- matrix(rnorm(L * .n_series), nrow = L, ncol = .n_series)

  expect_no_error(
    reconc_t(.A_2b, .mu_2b, y_train = y_mts, residuals = res_seas)
  )
})

test_that("reconc_t standard usage gives deterministic results for fixed inputs", {
  result1 <- reconc_t(.A_2b, .mu_2b, y_train = .y_mat, residuals = .res_mat)
  result2 <- reconc_t(.A_2b, .mu_2b, y_train = .y_mat, residuals = .res_mat)

  expect_equal(result1$bottom_rec_mean, result2$bottom_rec_mean)
  expect_equal(result1$bottom_rec_scale_matrix, result2$bottom_rec_scale_matrix)
  expect_equal(result1$bottom_rec_df, result2$bottom_rec_df)
})


# 12. Regression test: hard-coded reference values ----------------------------
# Reference computed with:
#   set.seed(123)
#   n_series <- 3L; L_train <- 50L
#   A_2b <- matrix(c(1,1), nrow=1)
#   y_mat <- matrix(rnorm(L_train * n_series, mean=10), nrow=L_train, ncol=n_series)
#   res_mat <- matrix(rnorm(L_train * n_series), nrow=L_train, ncol=n_series)
#   mu_2b <- c(11, 4, 6)
#   reconc_t(A_2b, mu_2b, y_train=y_mat, residuals=res_mat, return_parameters=TRUE)

test_that("reconc_t standard usage matches hard-coded reference values", {
  set.seed(123)
  n_series <- 3L
  L_train  <- 50L
  A        <- matrix(c(1, 1), nrow = 1)
  y_mat    <- matrix(rnorm(L_train * n_series, mean = 10), nrow = L_train, ncol = n_series)
  res_mat  <- matrix(rnorm(L_train * n_series),            nrow = L_train, ncol = n_series)
  mu       <- c(11, 4, 6)

  result <- suppressWarnings(
    reconc_t(A, mu, y_train = y_mat, residuals = res_mat, return_parameters = TRUE)
  )

  # bottom_rec_mean
  expect_equal(result$bottom_rec_mean,
    c(4.314540, 6.391355),
    tolerance = 1e-5
  )

  # bottom_rec_scale_matrix
  expect_equal(result$bottom_rec_scale_matrix,
    matrix(c(0.6503992, -0.3002090, -0.3002090, 0.5975068), nrow = 2),
    tolerance = 1e-5
  )

  # bottom_rec_df
  expect_equal(result$bottom_rec_df, 54)

  # posterior_nu
  expect_equal(result$posterior_nu, 55)
})

Try the bayesRecon package in your browser

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

bayesRecon documentation built on March 8, 2026, 9:08 a.m.