tests/testthat/test-focei-syminvchol.R

## #1079 / rxode2#1365: rxSymInvCholCreate() refuses a POSITIVE-DEFINITE omega
## whose zero pattern is not block-decomposable, so the old chol() guard let it
## through and the setup died with "theta has to have N elements".  Cheap (one
## small fit), so this stays in the essential push/PR subset.

nmTest({
  .blockZeroMod <- function() {
    ini({
      tka <- 0.45
      tcl <- 1
      tv <- 3.45
      ## the (eta.ka, eta.v) covariance is declared at exactly 0 INSIDE the
      ## block -- positive definite, but not block-decomposable
      eta.ka + eta.cl + eta.v ~ c(0.1,
                                  0.01, 0.1,
                                  0.00, 0.01, 0.1)
      add.sd <- 0.7
    })
    model({
      ka <- exp(tka + eta.ka)
      cl <- exp(tcl + eta.cl)
      v <- exp(tv + eta.v)
      linCmt() ~ add(add.sd)
    })
  }

  test_that("the declared omega really is the rxode2#1365 shape", {
    .ui <- rxode2::rxUiDecompress(rxode2::rxode2(.blockZeroMod))
    .om <- .ui$omega
    expect_true(all(eigen(.om)$values > 0))
    expect_equal(nrow(.omegaBlockZeros(.om)), 1L)
    ## the raw call is what used to abort the fit
    expect_error(rxode2::rxSymInvCholCreate(mat = .om, diag.xform = "sqrt"), "theta has to have")
  })

  test_that(".foceiSymInvCholCreate fills a block-internal zero", {
    .ui <- rxode2::rxUiDecompress(rxode2::rxode2(.blockZeroMod))
    .om <- .ui$omega
    expect_warning(.r <- .foceiSymInvCholCreate(.om, "sqrt", NULL), "omega block zero cov is estimated")
    ## the mechanism: the returned matrix is the FILLED one and the inverse
    ## carries the full dense-block parameter count (3 diag + 3 off-diag)
    expect_equal(length(.r$rxInv$theta), 6L)
    expect_equal(nrow(.omegaBlockZeros(.r$mat)), 0L)
    ## it names which random effects were involved
    .w <- tryCatch(.foceiSymInvCholCreate(.om, "sqrt", NULL), warning = function(w) conditionMessage(w))
    expect_true(grepl("eta.ka", .w, fixed = TRUE))
    expect_true(grepl("eta.v", .w, fixed = TRUE))
  })

  test_that(".foceiSymInvCholCreate is a no-op on an acceptable omega", {
    .om <- matrix(c(0.1, 0.01, 0.01, 0.1), 2, 2, dimnames = list(c("eta.ka", "eta.cl"), c("eta.ka", "eta.cl")))
    expect_warning(.r <- .foceiSymInvCholCreate(.om, "sqrt", NULL), NA)
    expect_equal(.r$mat, .om)
    expect_equal(length(.r$rxInv$theta), 3L)
  })

  test_that(".foceiOptEnvSetupBounds carries the fill into the bounds", {
    .ui <- rxode2::rxUiDecompress(rxode2::rxode2(.blockZeroMod))
    assign("control", foceiControl(), envir = .ui)
    .env <- new.env(parent = emptyenv())
    .env$etaNames <- c("eta.ka", "eta.cl", "eta.v")
    expect_warning(.foceiOptEnvSetupBounds(.ui, .env), "omega block zero cov is estimated")
    expect_false(is.null(.env$rxInv))
    ## nomega and the bound vectors agree with the (filled) parameter count --
    ## the a/b bound matrices are built from the SAME repaired omega, so their
    ## theta vectors line up row for row with env$rxInv's
    expect_equal(rxode2::rxGetControl(.ui, "nomega", -1L), 6L)
    expect_equal(length(.env$lower), length(.env$upper))
    expect_equal(length(.env$lower), rxode2::rxGetControl(.ui, "ntheta", 0L) + 6L)
  })

  test_that("a FOCEi fit with a block-internal omega zero runs (#1079)", {
    .fit <- nlmixr2(
      .blockZeroMod,
      nlmixr2data::theo_sd,
      est = "focei",
      control = foceiControl(
        maxOuterIterations = 0,
        maxInnerIterations = 10,
        covMethod = "",
        calcTables = FALSE,
        print = 0
      )
    )
    expect_true(inherits(.fit, "nlmixr2FitCore"))
    ## the fit estimated the previously-structural zero rather than erroring
    expect_equal(dim(.fit$omega), c(3L, 3L))
    ## and it said so, naming the random effects
    expect_true(any(grepl("omega block zero cov is estimated", .fit$runInfo)))
  })

  test_that("est='vae' survives the same omega (#1079)", {
    .fit <- suppressMessages(
      nlmixr2(
        .blockZeroMod,
        nlmixr2data::theo_sd,
        est = "vae",
        control = vaeControl(iters = 3, itersBurnIn = 2, calcTables = FALSE)
      )
    )
    expect_true(inherits(.fit, "nlmixr2FitCore"))
    expect_equal(dim(.fit$omega), c(3L, 3L))
  })

  test_that("a non-contiguous correlated block is filled, not flattened", {
    ## eta1 correlates with eta3 and eta2 sits between them: every component is
    ## dense, so a component-only rule calls this fine -- but the call refuses
    ## it, and the repair ladder would then have dropped the 0.5 covariance for
    ## a floored diagonal.
    .nm <- c("eta.a", "eta.b", "eta.c")
    .om <- matrix(c(0.1, 0, 0.05, 0, 0.1, 0, 0.05, 0, 0.1), 3, 3, dimnames = list(.nm, .nm))
    expect_error(rxode2::rxSymInvCholCreate(mat = .om, diag.xform = "sqrt"))
    expect_warning(.r <- .foceiSymInvCholCreate(.om, "sqrt", NULL), "omega block zero cov is estimated")
    ## the covariance SURVIVED -- this is the check a floored-diagonal fallback
    ## would fail
    expect_equal(.r$mat[1, 3], 0.05)
    expect_equal(diag(.r$mat), diag(.om))
    expect_equal(length(.r$rxInv$theta), 6L)
  })

  test_that("a 2x2 block declaring a 0 covariance is left alone", {
    ## Two etas whose covariance is declared at exactly 0 are structurally
    ## uncorrelated -- the call accepts that, and the repair must not decide it
    ## knows better and start estimating a covariance the model did not ask for.
    .nm <- c("eta.ka", "eta.cl")
    .om <- matrix(c(0.1, 0, 0, 0.1), 2, 2, dimnames = list(.nm, .nm))
    expect_equal(nrow(.omegaBlockZeros(.om)), 0L)
    expect_warning(.r <- .foceiSymInvCholCreate(.om, "sqrt", NULL), NA)
    expect_equal(.r$mat, .om)
    expect_equal(length(.r$rxInv$theta), 2L)
  })

  test_that("the vae omega position list follows the FILLED omega (#1079)", {
    ## vaeOmegaSel is the 0-based position list the C++ fast path packs
    ## chol(Omega^-1) into, and it has to match rxSymInvCholCreate's parameter
    ## order.  Building it from the unrepaired omega would give 5 positions for
    ## a 6-parameter inverse.
    .theoZ <- function() {
      ini({
        lka <- log(1.8)
        lke <- log(0.086)
        lV <- log(32)
        eta.ka + eta.ke + eta.V ~ c(0.3,
                                    0.00, 0.03,
                                    0.02, 0.005, 0.03)
        add.err <- 0.7
      })
      model({
        ka <- exp(lka + eta.ka)
        ke <- exp(lke + eta.ke)
        V <- exp(lV + eta.V)
        d/dt(depot) = -ka * depot
        d/dt(central) = ka * depot - ke * central
        cp <- central / V
        cp ~ add(add.err)
      })
    }
    .ui <- rxode2::assertRxUi(.theoZ)
    ## the declared omega really needs the repair
    expect_equal(nrow(.omegaBlockZeros(.ui$omega)), 1L)
    .ctl <- vaeControl()
    .n <- length(unique(nlmixr2data::theo_sd$ID))
    set.seed(3)
    .etaMat <- matrix(rnorm(.n * 3, 0, 0.1), .n, 3)
    .prep <- .vaeDataPrep(.ui, nlmixr2data::theo_sd)
    .env <- .vaeInnerSetup(.ui, nlmixr2data::theo_sd, .etaMat, .ctl)
    on.exit(.vaeInnerFree(), add = TRUE)
    expect_equal(nrow(.env$vaeOmegaSel), length(.env$rxInv$theta))
    expect_equal(length(.env$rxInv$theta), 6L)
    ## the C++ fast path packs the OUTER (unrepaired) omega; it must still land
    ## where the repaired full re-setup does
    vaeInnerUpdatePar_(as.numeric(.prep$th), .prep$omegaMat)
    .fast <- .vaeInnerEval(.etaMat, .ctl, grad = TRUE)
    .vaeInnerUpdate(.env, .prep$th, .prep$omegaMat, .etaMat)
    .ref <- .vaeInnerEval(.etaMat, .ctl, grad = TRUE)
    expect_lt(max(abs(.fast$obj - .ref$obj)), 1e-8)
    expect_lt(max(abs(.fast$lp - .ref$lp)), 1e-8)
    ## and the 1e-10 fill is inert: declaring that covariance explicitly gives
    ## the same objective as leaving it at 0
    .omTiny <- .prep$omegaMat
    .omTiny[1, 3] <- .omTiny[3, 1] <-
      1e-10 * sqrt(.omTiny[1, 1] * .omTiny[3, 3])
    .vaeInnerUpdate(.env, .prep$th, .omTiny, .etaMat)
    .tiny <- .vaeInnerEval(.etaMat, .ctl, grad = TRUE)
    expect_equal(.ref$obj, .tiny$obj, tolerance = 1e-10)
  })

  test_that("the last rungs: a floored diagonal, then an actionable error", {
    ## A structurally acceptable but NON positive-definite omega: there is
    ## nothing to fill, so the ladder has to run out to the floored diagonal.
    .nm <- c("eta1", "eta2")
    .om <- matrix(c(1, 2, 2, 1), 2, 2, dimnames = list(.nm, .nm))
    expect_true(any(eigen(.om)$values < 0))
    expect_equal(nrow(.omegaBlockZeros(.om)), 0L)
    expect_null(.omegaFillBlockZeros(.om))
    expect_warning(.r <- .foceiSymInvCholCreate(.om, "sqrt", NULL), "floored diagonal")
    ## the diagonal is kept (it is already above the floor), the covariance goes
    expect_equal(diag(.r$mat), c(eta1 = 1, eta2 = 1))
    expect_equal(.r$mat[1, 2], 0)
    ## a non-finite omega floors to the minimum instead of erroring
    .nan <- matrix(NaN, 2, 2, dimnames = list(.nm, .nm))
    expect_warning(.rn <- .foceiSymInvCholCreate(.nan, "sqrt", NULL), "floored diagonal")
    expect_equal(unname(diag(.rn$mat)), c(1e-6, 1e-6))
    ## with fallback=FALSE (the per-step vae rebuild) only the fill may run, so
    ## the same omega errors -- and the message names the random effects
    expect_error(.foceiSymInvCholCreate(.om, "sqrt", NULL, fallback = FALSE), "eta1, eta2")
  })

  test_that("a repeated same() block keeps its sharing through the fill", {
    ## The fill is the SAME rule in every block (cor * sqrt(d_i d_j)), so
    ## identical repeated blocks stay identical and the same() map survives --
    ## which it must, or the bound matrices below it no longer mirror the
    ## right rows.
    .d <- nlmixr2data::theo_sd
    .d$occ <- 1 + (.d$TIME >= 5)
    .f <- function() {
      ini({
        tka <- 0.45
        tcl <- 1
        tv <- 3.45
        add.sd <- 0.7
        eta.ka ~ 0.6
        iov.ka + iov.cl + iov.v ~ c(0.1,
                                    0.02, 0.2,
                                    0.01, 0.03, 0.3) | occ
      })
      model({
        ka <- exp(tka + eta.ka + iov.ka)
        cl <- exp(tcl + iov.cl)
        v <- exp(tv + iov.v)
        linCmt() ~ add(add.sd)
      })
    }
    .ui <- .uiApplyIov(rxode2::rxode2(.f()), "focei", .d, foceiControl(iovMethod = "omega"))$ui
    .om <- .ui$omega
    .sm <- .ui$omegaSameMap
    ## eta.ka plus two repeated 3x3 blocks, sharing 7 parameters
    expect_equal(dim(.om), c(7L, 7L))
    expect_equal(.sm, c(0L, 0L, 0L, 0L, 2L, 3L, 4L))
    .nTheta <- length(rxode2::rxSymInvCholCreate(.om, "sqrt", same = .sm)$theta)
    expect_equal(.nTheta, 7L)
    ## zero the SAME within-block cell in both repeats
    .z <- .om
    .z[2, 4] <- .z[4, 2] <- 0
    .z[5, 7] <- .z[7, 5] <- 0
    expect_equal(nrow(.omegaBlockZeros(.z)), 2L)
    expect_warning(.r <- .foceiSymInvCholCreate(.z, "sqrt", .sm), "omega block zero cov is estimated")
    ## the sharing survived: same map kept, parameter count unchanged, and the
    ## two blocks are still identical after the fill
    expect_equal(.r$same, .sm)
    expect_equal(length(.r$rxInv$theta), .nTheta)
    expect_equal(unname(.r$mat[2:4, 2:4]), unname(.r$mat[5:7, 5:7]))
    ## with same(), only the MASTER block's pattern is parameterized, so a zero
    ## in a REPEAT alone needs no repair at all
    .one <- .om
    .one[5, 7] <- .one[7, 5] <- 0
    expect_warning(.r2 <- .foceiSymInvCholCreate(.one, "sqrt", .sm), NA)
    expect_equal(.r2$same, .sm)
    expect_equal(length(.r2$rxInv$theta), .nTheta)
  })

  test_that("dropping same() sharing is never silent", {
    ## The ladder may estimate the repeated blocks independently to get an
    ## inverse at all.  That changes what is estimated, so it has to be said --
    ## a non-PD omega runs out to the floored diagonal, which cannot share.
    .nm <- paste0("eta", 1:4)
    .om <- matrix(0, 4, 4, dimnames = list(.nm, .nm))
    diag(.om) <- 1
    .om[1, 2] <- .om[2, 1] <- 2
    .om[3, 4] <- .om[4, 3] <- 2
    .sm <- c(0L, 0L, 1L, 2L)
    .w <- NULL
    withCallingHandlers(.foceiSymInvCholCreate(.om, "sqrt", .sm), warning = function(w) {
      .w <<- c(.w, conditionMessage(w))
      invokeRestart("muffleWarning")
    })
    expect_true(any(grepl("floored diagonal", .w, fixed = TRUE)))
    expect_true(any(grepl("same() sharing dropped", .w, fixed = TRUE)))
    ## and every note stays on one $runInfo line
    expect_true(all(nchar(.w) < 75L))
  })
})

Try the nlmixr2est package in your browser

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

nlmixr2est documentation built on Sept. 20, 2026, 9:08 a.m.