R/mlvar.R

Defines functions summary.net_mlvar print.net_mlvar .mlvar_contemporaneous_fixed .mlvar_compute_between_from_gamma .mlvar_force_positive .mlvar_aveLag .mlvar_estimate_contemporaneous .mlvar_estimate_unique .mlvar_estimate_lmer .mlvar_augment_data .mlvar_prepare_data .mlvar_row .mlvar_vec coefs.default coefs.net_mlvar coefs .mlvar_reference_to_idiographic .mlvar_reference_engine fit_mlvar

Documented in coefs coefs.default coefs.net_mlvar fit_mlvar print.net_mlvar summary.net_mlvar

# ---- Multilevel Vector Autoregression (mlVAR) ----

#' Build a Multilevel Vector Autoregression (mlVAR) network
#'
#' @description Estimates three networks from ESM/EMA panel data, matching
#'   validated `mlVAR::mlVAR()` configurations at machine precision: (1) a directed
#'   temporal network of fixed-effect lagged regression coefficients, (2)
#'   an undirected contemporaneous network of partial correlations among
#'   residuals, and (3) an undirected between-subjects network of partial
#'   correlations derived from the person-mean fixed effects.
#'
#' @details The algorithm follows mlVAR's lmer pipeline exactly:
#' \enumerate{
#'   \item Drop rows with NA in id/day/beep and optionally grand-mean
#'         standardize each variable.
#'   \item Expand the per-(id, day) beep grid and right-join original
#'         values, producing the augmented panel (`augData`).
#'   \item Add within-person lagged predictors (`L1_*`) and person-mean
#'         predictors (`PM_*`).
#'   \item For each outcome variable fit
#'         `lmer(y ~ within + between-except-own-PM + (1 | id))` with
#'         `REML = FALSE`. Collect the fixed-effect temporal matrix `B`,
#'         between-effect matrix `Gamma`, random-intercept SDs (`mu_SD`),
#'         and lmer residual SDs.
#'   \item Contemporaneous network:
#'         `cor2pcor(D %*% cov2cor(cor(resid)) %*% D)`.
#'   \item Between-subjects network:
#'         `cor2pcor(pseudoinverse(forcePositive(D (I - Gamma))))`.
#' }
#'
#' The committed oracle matrix validates fixed temporal/contemporaneous `lmer`
#' fits at lags 1 and 1+2, preprocessing controls (`scale`, `scaleWithin`,
#' `compareToLags`, `trueMeans`, and position detrending), and lag-1
#' `estimator = "lm"`, `temporal = "unique"` fits across every supported
#' contemporaneous structure. Other configurations carry a narrower
#' declaration available through [equivalence()].
#'
#' @param data A `data.frame` containing the panel data.
#' @param vars Character vector of variable column names to model.
#' @param id Character string naming the person-ID column.
#' @param day Character string naming the day/session column, or `NULL`.
#'   When provided, lag pairs are only formed within the same day.
#' @param beep Character string naming the measurement-occasion column, or
#'   `NULL`. When `NULL`, row position within each (id, day) is used.
#' @param lags One or more unique positive integer lag orders (mlVAR's `lags`).
#' @param estimator Character. Frequentist estimator: `"lmer"` (multilevel)
#'   or `"lm"` (separate person-specific models, requiring
#'   `temporal = "unique"`). The legacy value `"Mplus"` selects
#'   `engine = "mplus"`.
#' @param temporal,contemporaneous Character random-effect structure. The native
#'   frequentist engine supports fixed, correlated, orthogonal, and unique
#'   person-specific effects. The Bayesian engine maps correlated/orthogonal/
#'   unique temporal effects to its full random-slope model.
#' @param AR Logical. If `TRUE`, estimate only autoregressive (own-lag) temporal
#'   effects, giving a diagonal temporal matrix (matches `mlVAR(AR = TRUE)`).
#'   For the native frequentist/reference path this requires `estimator =
#'   "lmer"`. Default `FALSE`.
#' @param scale Logical. If `TRUE`, each variable is grand-mean centred and
#'   divided by its pooled SD before augmentation (mlVAR's `scale`). Default
#'   `FALSE`. (The deprecated `standardize` is an alias.)
#' @param scaleWithin Logical. If `TRUE`, additionally scale within person
#'   (mlVAR's `scaleWithin`). Default `FALSE`.
#' @param nCores Positive integer number of outcome models to fit in parallel.
#'   Uses forked workers on Unix-like systems and a PSOCK cluster on Windows.
#' @param verbose Logical. Emit progress messages. Default `FALSE`.
#' @param lag Deprecated alias for `lags`.
#' @param standardize Deprecated alias for `scale`.
#' @param engine Estimation engine: `"frequentist"` (native lme4/base R),
#'   `"bayes"` (the native DSEM sampler), `"mplus"` (licensed Mplus through
#'   [fit_mlvar_mplus()]), or `"reference"` (direct `mlVAR::mlVAR()` followed by
#'   conversion to idiographic's tidy result contract).
#' @param standardize_mode Easy standardization vocabulary: `"none"`,
#'   `"global"`, `"within"`, or `"both"`. When supplied it sets `scale` and
#'   `scaleWithin`; the logical legacy arguments remain supported.
#' @param missing Missing-data policy: `"omit"`, `"fail"`, or `"model"`.
#'   `"fail"` checks model variables and ID/day/beep ordering keys. Within-model
#'   imputation is available with the Bayesian random-slope engine.
#' @param compare_to_lags Optional positive lag vector used only to align the
#'   analysis rows when comparing models with different lag orders. It must
#'   include every fitted value in `lags`; for example, use `c(1, 2)` while
#'   fitting lag 1 for a comparison with a lag-2 model.
#' @param true_means Optional data frame containing `id` and all `vars`, used as
#'   known person means instead of sample means.
#' @param detrend `"none"` or `"position"`. Position detrending standardizes
#'   each measurement position across subjects before model standardization.
#' @param na_rm Logical legacy spelling for whether incomplete model rows are
#'   omitted. `FALSE` is equivalent to `missing = "fail"`.
#' @param orthogonal Deprecated upstream compatibility flag. When supplied it
#'   sets `temporal = "orthogonal"` (`TRUE`) or `"correlated"` (`FALSE`).
#' @param ... Engine-specific controls. For example `n_iter`, `n_chains`, and
#'   `residual` for the Bayesian engine, or Mplus controls for the Mplus engine.
#'
#' @section Observation keys:
#' When `beep` is supplied, every complete `(id, day, beep)` key (or `(id,
#' beep)` when `day = NULL`) must be unique. Duplicate keys often indicate that
#' a study-period/session column was lost during data conversion. Because
#' upstream join behaviour is row-order dependent in that case, `fit_mlvar()`
#' errors and asks you to resolve or explicitly deduplicate the source data.
#'
#' @return A dual-class `c("net_mlvar", "netobject_group")` object — a
#'   named list of three full netobjects, one per network, plus
#'   model-level metadata stored as attributes. Each element is a
#'   standard `c("netobject", "cograph_network")` weight-matrix wrapper
#'   (no raw `$data`), so `print()`, `summary()`, [coefs()], and
#'   `cograph::splot(fit$temporal)` work directly. The three constituents
#'   are matrix-wrapped and carry no underlying panel data, so any
#'   data-resampling workflow (bootstrap, reliability, stability) must start
#'   from the original panel rather than from these wrappers.
#'   Structure:
#'   \describe{
#'     \item{`fit$temporal`}{Directed netobject for the `d x d` matrix of
#'       fixed-effect lagged coefficients. `$weights[i, j]` is the effect
#'       of variable j at t-lag on variable i at t. `method =
#'       "mlvar_temporal"`, `directed = TRUE`.}
#'     \item{`fit$contemporaneous`}{Undirected netobject for the `d x d`
#'       partial-correlation network of within-person lmer residuals.
#'       `method = "mlvar_contemporaneous"`, `directed = FALSE`.}
#'     \item{`fit$between`}{Undirected netobject for the `d x d`
#'       partial-correlation network of person means, derived from
#'       `D (I - Gamma)`. `method = "mlvar_between"`, `directed = FALSE`.
#'       \strong{Convention:} when a random-intercept SD is 0 the between
#'       network is not estimable; idiographic returns an all-zero matrix (with a
#'       warning) as a plotting-oriented convention, whereas `mlVAR` returns
#'       an all-`NA` matrix. The contemporaneous network follows the same
#'       zero-on-degeneracy convention. This is a deliberate departure from
#'       strict reference equivalence in the singular case.}
#'     \item{`attr(fit, "coefs")` / [coefs()]}{Tidy `data.frame` with one
#'       row per `(outcome, predictor)` pair and columns `outcome`,
#'       `predictor`, `beta`, `se`, `t`, `p`, `ci_lower`, `ci_upper`,
#'       `significant`. Filter, sort, or plot with base R or the tidyverse.
#'       Retrieve with `coefs(fit)`.}
#'     \item{`attr(fit, "n_obs")`}{Number of rows in the augmented panel
#'       after na.omit.}
#'     \item{`attr(fit, "n_subjects")`}{Number of unique subjects remaining.}
#'     \item{`attr(fit, "lag")`}{Lag order used.}
#'     \item{`attr(fit, "standardize")`}{Logical; whether pre-augmentation
#'       standardization was applied.}
#'   }
#'
#' @examplesIf requireNamespace("lme4", quietly = TRUE)
#' \donttest{
#' set.seed(1)
#' n_id <- 8; n_t <- 30; vars <- c("A", "B", "C")
#' rows <- lapply(seq_len(n_id), function(i) {
#'   m <- as.data.frame(matrix(rnorm(n_t * 3), ncol = 3))
#'   names(m) <- vars
#'   m$id <- i; m$day <- 1L; m$beep <- seq_len(n_t)
#'   m
#' })
#' d <- do.call(rbind, rows)
#' fit <- fit_mlvar(d, vars = vars, id = "id", day = "day", beep = "beep")
#' print(fit)
#' summary(fit)
#' }
#'
#' @param min_obs Integer or `NULL`. Keep only subjects with at least this many
#'   observations (counts taken from `data`).
#' @param subject Optional vector naming the exact subject(s) to analyse.
#' @seealso [fit_gimme()], [fit_graphical_var()], [as_netobject()]
#' @export
fit_mlvar <- function(data, vars, id,
                        day = NULL, beep = NULL,
                        lags = 1L,
                        estimator = c("lmer", "default", "lm", "Mplus"),
                        temporal = c("fixed", "correlated", "orthogonal",
                                     "unique", "default"),
                        contemporaneous = c("fixed", "correlated", "orthogonal",
                                            "unique", "default"),
                        AR = FALSE,
                        scale = FALSE,
                        scaleWithin = FALSE,
                        nCores = 1L,
                        verbose = FALSE,
                        lag = NULL,
                        standardize = NULL,
                        min_obs = NULL,
                        subject = NULL,
                        engine = c("frequentist", "bayes", "mplus", "reference"),
                        standardize_mode = NULL,
                        missing = c("omit", "fail", "model"),
                        compare_to_lags = NULL,
                        true_means = NULL,
                        detrend = c("none", "position"),
                        na_rm = TRUE,
                        orthogonal = NULL,
                        ...) {
  estimator       <- match.arg(estimator)
  temporal        <- match.arg(temporal)
  contemporaneous <- match.arg(contemporaneous)
  engine           <- match.arg(engine)
  missing          <- match.arg(missing)
  detrend          <- match.arg(detrend)

  if (identical(estimator, "Mplus")) engine <- "mplus"
  if (identical(estimator, "default")) estimator <- "lmer"
  if (identical(temporal, "default")) temporal <- "fixed"
  if (identical(contemporaneous, "default")) contemporaneous <- "fixed"
  if (!is.null(orthogonal)) {
    .ido_check_flag(orthogonal, "orthogonal")
    temporal <- if (isTRUE(orthogonal)) "orthogonal" else "correlated"
    warning("`orthogonal` is deprecated; setting `temporal = \"",
            temporal, "\"`.", call. = FALSE)
  }

  # `lag` (idiographic) and `standardize` (idiographic) are deprecated aliases of the
  # mlVAR-API names `lags` / `scale`. If the caller sets BOTH the canonical name
  # and the deprecated alias to conflicting values, honour the canonical name
  # and warn rather than silently letting the alias win.
  if (!is.null(lag)) {
    if (!missing(lags) && !identical(lags, lag)) {
      warning("Both `lags` and the deprecated `lag` were set and disagree; ",
              "using lags = ", lags, " and ignoring lag = ", lag, ".",
              call. = FALSE)
    } else {
      lags <- lag
    }
  }
  if (!is.null(standardize)) {
    if (!missing(scale) && !identical(scale, standardize)) {
      warning("Both `scale` and the deprecated `standardize` were set and ",
              "disagree; using scale = ", scale, " and ignoring standardize = ",
              standardize, ".", call. = FALSE)
    } else {
      scale <- standardize
    }
  }

  if (!is.null(standardize_mode)) {
    mode <- match.arg(standardize_mode, c("none", "global", "within", "both"))
    scale <- mode %in% c("global", "both")
    scaleWithin <- mode %in% c("within", "both")
  }
  .ido_check_flag(na_rm, "na_rm")
  if (!isTRUE(na_rm)) missing <- "fail"
  na_rm <- !identical(missing, "fail")

  # Shared validation happens before engine dispatch so every backend reports
  # the same clear contract errors instead of leaking backend-specific
  # subscript/stopifnot failures or silently ignoring front-door controls.
  if (!is.data.frame(data)) {
    stop("`data` must be a data frame.", call. = FALSE)
  }
  if (!(is.character(vars) && length(vars) >= 2L && !anyNA(vars) &&
        !anyDuplicated(vars) && all(nzchar(vars)))) {
    stop("`vars` must contain at least two unique, non-empty column names.",
         call. = FALSE)
  }
  if (!(is.character(id) && length(id) == 1L && !is.na(id) && nzchar(id))) {
    stop("`id` must be one non-empty column name.", call. = FALSE)
  }
  required <- c(vars, id, day, beep)
  required <- required[!vapply(required, is.null, logical(1))]
  missing_cols <- setdiff(required, names(data))
  if (length(missing_cols)) {
    stop("Columns not found in data: ", paste(missing_cols, collapse = ", "),
         call. = FALSE)
  }
  .ido_check_col(day, "day", data)
  .ido_check_col(beep, "beep", data)
  .ido_check_numeric_vars(data, vars, check_variance = FALSE)
  .ido_check_flag(AR, "AR")
  .ido_check_flag(scale, "scale")
  .ido_check_flag(scaleWithin, "scaleWithin")
  .ido_check_flag(verbose, "verbose")
  if (!(is.numeric(nCores) && length(nCores) == 1L && is.finite(nCores) &&
        nCores == floor(nCores) && nCores >= 1L)) {
    stop("`nCores` must be one positive integer.", call. = FALSE)
  }
  nCores <- as.integer(nCores)
  if (!(is.numeric(lags) && length(lags) >= 1L && all(is.finite(lags)) &&
        all(lags >= 1L) && all(lags == floor(lags)) && !anyDuplicated(lags))) {
    stop("`lags` must contain unique positive whole numbers.", call. = FALSE)
  }
  lags <- sort(as.integer(lags))
  if (!is.null(compare_to_lags)) {
    if (!is.numeric(compare_to_lags) || !length(compare_to_lags) ||
        any(!is.finite(compare_to_lags)) || any(compare_to_lags < 1L) ||
        any(compare_to_lags != floor(compare_to_lags))) {
      stop("`compare_to_lags` must be NULL or positive whole-number lags.",
           call. = FALSE)
    }
    compare_to_lags <- sort(unique(as.integer(compare_to_lags)))
    if (!all(lags %in% compare_to_lags)) {
      stop("`compare_to_lags` must include every fitted value in `lags`; ",
           "for example, use `compare_to_lags = c(1, 2)` when fitting lag 1 ",
           "on rows aligned to a lag-2 model.", call. = FALSE)
    }
  }
  if (engine %in% c("frequentist", "reference")) {
    if (identical(estimator, "lm") && !identical(temporal, "unique")) {
      stop("`estimator = \"lm\"` requires `temporal = \"unique\"`, matching mlVAR.",
           call. = FALSE)
    }
    if (identical(temporal, "unique") && !identical(estimator, "lm")) {
      stop("`temporal = \"unique\"` requires `estimator = \"lm\"`, matching mlVAR.",
           call. = FALSE)
    }
    if (isTRUE(AR) && !identical(estimator, "lmer")) {
      stop("`AR = TRUE` requires `estimator = \"lmer\"`, matching mlVAR.",
           call. = FALSE)
    }
  }
  data <- .ido_keep(data, id, min_obs, subject)
  if (identical(missing, "fail") &&
      anyNA(data[, required, drop = FALSE])) {
    stop("Missing model or ordering values found with `missing = \"fail\"`.",
         call. = FALSE)
  }
  key_cols <- c(id, day, beep)
  key_cols <- key_cols[!vapply(key_cols, is.null, logical(1))]
  if (!is.null(beep) && length(key_cols) >= 2L) {
    complete_key <- stats::complete.cases(data[, key_cols, drop = FALSE])
    duplicate_key <- duplicated(data[complete_key, key_cols, drop = FALSE])
    if (any(duplicate_key)) {
      stop(
        "Duplicate observation keys found: ", sum(duplicate_key),
        " row(s) repeat `", paste(key_cols, collapse = "`, `"),
        "`. Resolve the period/session structure or deduplicate explicitly ",
        "before fitting; duplicate keys do not have deterministic mlVAR ",
        "semantics.",
        call. = FALSE
      )
    }
  }

  engine_args <- list(...)
  if (engine == "bayes") {
    unsupported <- c(
      AR = isTRUE(AR),
      nCores = nCores != 1L,
      compare_to_lags = !is.null(compare_to_lags),
      true_means = !is.null(true_means),
      detrend = !identical(detrend, "none"),
      estimator = !identical(estimator, "lmer")
    )
    if (any(unsupported)) {
      stop("The Bayesian engine does not accept front-door control(s): ",
           paste(names(unsupported)[unsupported], collapse = ", "),
           ". Use its explicit MCMC controls in `...`.", call. = FALSE)
    }
    if (!identical(lags, 1L)) {
      stop("The Bayesian engine currently supports `lags = 1` only.",
           call. = FALSE)
    }
    bayes_temporal <- if (temporal == "fixed") "fixed" else "random"
    if (contemporaneous != "fixed") {
      stop("The native Bayesian engine currently supports ",
           "`contemporaneous = \"fixed\"`; use `residual = \"random\"` for ",
           "person-specific innovation covariances.", call. = FALSE)
    }
    if (identical(missing, "model")) {
      if (bayes_temporal != "random") {
        stop("`missing = \"model\"` requires random temporal effects for the ",
             "Bayesian engine.", call. = FALSE)
      }
      engine_args$impute <- TRUE
    }
    args <- c(list(data = data, vars = vars, id = id, day = day, beep = beep,
                   lags = lags, temporal = bayes_temporal,
                   contemporaneous = "fixed", scale = scale,
                   scaleWithin = scaleWithin, min_obs = NULL, subject = NULL,
                   verbose = verbose), engine_args)
    return(do.call(fit_mlvar_bayes, args))
  }

  if (engine == "mplus") {
    unsupported <- c(
      AR = isTRUE(AR),
      compare_to_lags = !is.null(compare_to_lags),
      true_means = !is.null(true_means),
      detrend = !identical(detrend, "none")
    )
    if (any(unsupported)) {
      stop("The Mplus engine does not accept front-door control(s): ",
           paste(names(unsupported)[unsupported], collapse = ", "), ".",
           call. = FALSE)
    }
    if (!identical(lags, 1L)) {
      stop("The Mplus engine currently supports `lags = 1` only.",
           call. = FALSE)
    }
    if (temporal == "unique" || contemporaneous == "unique") {
      stop("The Mplus engine does not support `unique` temporal or ",
           "contemporaneous effects.", call. = FALSE)
    }
    if (!estimator %in% c("lmer", "Mplus")) {
      stop("The Mplus engine does not use `estimator = \"", estimator,
           "\"`.", call. = FALSE)
    }
    if (missing == "model") {
      stop("`missing = \"model\"` is implicit in Mplus; do not request it ",
           "through the frequentist front end.", call. = FALSE)
    }
    args <- c(list(data = data, vars = vars, id = id, day = day, beep = beep,
                   lags = lags, temporal = temporal,
                   contemporaneous = contemporaneous, nCores = nCores,
                   scale = scale, scaleWithin = scaleWithin,
                   min_obs = NULL, subject = NULL, verbose = verbose),
              engine_args)
    return(do.call(fit_mlvar_mplus, args))
  }

  if (engine == "reference") {
    if (identical(missing, "model")) {
      stop("`missing = \"model\"` is available only for `engine = \"bayes\"`.",
           call. = FALSE)
    }
    return(.mlvar_reference_engine(
      data = data, vars = vars, id = id, day = day, beep = beep, lags = lags,
      estimator = estimator, temporal = temporal,
      contemporaneous = contemporaneous, nCores = nCores, verbose = verbose,
      scale = scale, scaleWithin = scaleWithin, AR = AR,
      compare_to_lags = compare_to_lags, true_means = true_means,
      na_rm = na_rm, detrend = detrend, extra = engine_args
    ))
  }

  if (identical(missing, "model")) {
    stop("`missing = \"model\"` is available only for `engine = \"bayes\"`.",
         call. = FALSE)
  }
  if (length(engine_args)) {
    stop("Unused frequentist engine argument(s) in `...`: ",
         paste(names(engine_args) %||% rep("<unnamed>", length(engine_args)),
               collapse = ", "),
         ". Use explicit `fit_mlvar()` arguments or select an engine that ",
         "accepts these controls.", call. = FALSE)
  }
  if (verbose) message("Preparing and augmenting panel ...")
  prepared <- .mlvar_prepare_data(data, vars, id, day, beep, scale,
                                  full_detrend = identical(detrend, "position"))
  aug <- .mlvar_augment_data(
    prepared, vars, id, day, beep, lag = lags, scaleWithin = scaleWithin,
    compare_to_lags = compare_to_lags, true_means = true_means,
    # Structural lag rows are necessarily incomplete and are always removed;
    # `missing = "fail"` has already checked the user's observed values above.
    missing = "omit"
  )
  if (verbose) message("Fitting ", length(vars), " ", estimator, " models ...")
  Res <- if (identical(estimator, "lm") || identical(temporal, "unique")) {
    .mlvar_estimate_unique(aug$data, aug$predModel, vars, id, AR = AR,
                           contemporaneous = contemporaneous)
  } else {
    if (!requireNamespace("lme4", quietly = TRUE)) {
      stop(
        "This fit_mlvar() configuration requires the optional package 'lme4'. ",
        "Install it to use estimator = 'lmer', or use estimator = 'lm' for ",
        "the dependency-free native engine.",
        call. = FALSE
      )
    }
    .mlvar_estimate_lmer(
      aug$data, aug$predModel, vars, id, AR = AR, temporal = temporal,
      contemporaneous = contemporaneous, nCores = as.integer(nCores)
    )
  }

  # Wrap each of the three matrices as a full cograph_network netobject via
  # the package-wide `.ido_wrap()` constructor. Plotting is handled by
  # cograph's existing splot.netobject /
  # splot.cograph_network dispatch, which fires automatically because each
  # constituent here is a standard netobject.
  B_layers <- Res$temporal$B
  if (is.matrix(B_layers)) B_layers <- stats::setNames(list(B_layers),
                                                       paste0("lag", lags[1L]))
  temporal_nets <- lapply(seq_along(B_layers), function(k) {
    .ido_wrap(B_layers[[k]],
              method = if (length(B_layers) == 1L) "mlvar_temporal" else
                paste0("mlvar_temporal_", names(B_layers)[k]),
              directed = TRUE)
  })
  names(temporal_nets) <- if (length(B_layers) == 1L) "temporal" else
    paste0("temporal_", names(B_layers))
  contemporaneous_net <- .ido_wrap(Res$contemporaneous,
                                         method   = "mlvar_contemporaneous",
                                         directed = FALSE)
  between_net         <- .ido_wrap(Res$between,
                                         method   = "mlvar_between",
                                         directed = FALSE)

  nets <- c(temporal_nets, list(contemporaneous = contemporaneous_net,
                               between = between_net))

  # Model-level metadata lives in attributes so the list stays a pure
  # netobject_group (each element is a netobject). Use coefs(fit) to
  # retrieve the tidy coefs data.frame.
  attr(nets, "coefs")       <- Res$temporal$coefs
  attr(nets, "n_obs")       <- nrow(aug$data)
  attr(nets, "n_subjects")  <- length(unique(aug$data[[id]]))
  attr(nets, "lag")         <- lags
  attr(nets, "temporal_matrices") <- B_layers
  attr(nets, "temporal_subjects") <- Res$temporal$subjects %||% NULL
  attr(nets, "contemporaneous_subjects") <-
    Res$contemporaneous_subjects %||% NULL
  attr(nets, "standardize") <- scale
  attr(nets, "scale")       <- scale
  attr(nets, "scaleWithin") <- scaleWithin
  attr(nets, "AR")          <- AR
  attr(nets, "config") <- list(
    engine = "frequentist", estimator = estimator, temporal = temporal,
    contemporaneous = contemporaneous, lags = lags,
    compare_to_lags = compare_to_lags, standardize_mode = standardize_mode,
    true_means_supplied = !is.null(true_means),
    scale = scale, scaleWithin = scaleWithin, missing = missing,
    detrend = detrend, nCores = nCores
  )
  attr(nets, "group_col")   <- "network_type"

  class(nets) <- c("net_mlvar", "cograph_group", "netobject_group")
  nets
}

#' Run the current mlVAR implementation behind idiographic's tidy contract
#'
#' This backend is intentionally explicit (`engine = "reference"`): it is used
#' for reference-oracle work and for upstream-only modes while their native
#' equivalents are being validated. It never changes the default native path.
#' @noRd
.mlvar_reference_engine <- function(data, vars, id, day, beep, lags,
                                    estimator, temporal, contemporaneous,
                                    nCores, verbose, scale, scaleWithin, AR,
                                    compare_to_lags, true_means, na_rm, detrend,
                                    extra = list()) {
  if (!requireNamespace("mlVAR", quietly = TRUE)) {
    stop("Package 'mlVAR' is required for `engine = \"reference\"`.",
         call. = FALSE)
  }
  args <- list(
    data = data, vars = vars, idvar = id, lags = lags,
    estimator = estimator, temporal = temporal,
    contemporaneous = contemporaneous, nCores = nCores, verbose = verbose,
    scale = scale, scaleWithin = scaleWithin, AR = AR, na.rm = na_rm,
    full_detrend = identical(detrend, "position")
  )
  if (!is.null(day)) args$dayvar <- day
  if (!is.null(beep)) args$beepvar <- beep
  if (!is.null(compare_to_lags)) args$compareToLags <- compare_to_lags
  if (!is.null(true_means)) args$trueMeans <- true_means
  dup <- intersect(names(args), names(extra))
  if (length(dup)) {
    stop("Engine argument(s) supplied twice: ", paste(dup, collapse = ", "),
         call. = FALSE)
  }
  fit <- do.call(mlVAR::mlVAR, c(args, extra))
  .mlvar_reference_to_idiographic(fit, vars, lags, config = list(
    engine = "reference", estimator = estimator, temporal = temporal,
    contemporaneous = contemporaneous, scale = scale,
    scaleWithin = scaleWithin, AR = AR, id = id, day = day, beep = beep,
    compare_to_lags = compare_to_lags, detrend = detrend
  ))
}

#' Convert an mlVAR result to idiographic network layers
#' @noRd
.mlvar_reference_to_idiographic <- function(fit, vars, lags = 1L,
                                            config = list()) {
  if (!inherits(fit, "mlVAR")) {
    stop("Expected an object of class 'mlVAR'.", call. = FALSE)
  }
  p <- length(vars)
  pull_mean <- function(x, fallback = NULL) {
    if (!is.null(x) && !is.null(x$mean)) return(x$mean)
    if (!is.null(fallback)) return(fallback)
    matrix(0, p, p, dimnames = list(vars, vars))
  }
  beta <- pull_mean(fit$results$Beta)
  if (is.null(dim(beta))) beta <- matrix(beta, p, p)
  beta_layers <- if (length(dim(beta)) == 3L) {
    lapply(seq_len(dim(beta)[3L]), function(k) beta[, , k, drop = TRUE])
  } else {
    list(as.matrix(beta))
  }
  used_lags <- as.integer(lags)[seq_along(beta_layers)]
  beta_layers <- lapply(beta_layers, function(x) {
    x <- as.matrix(x); dimnames(x) <- list(vars, vars); x
  })
  names(beta_layers) <- paste0("lag", used_lags)

  theta <- as.matrix(pull_mean(fit$results$Theta$pcor))
  omega <- as.matrix(pull_mean(fit$results$Omega_mu$pcor))
  dimnames(theta) <- dimnames(omega) <- list(vars, vars)
  diag(theta) <- diag(omega) <- 0

  networks <- list()
  if (length(beta_layers) == 1L) {
    networks$temporal <- .ido_wrap(beta_layers[[1L]], "mlvar_temporal", TRUE)
  } else {
    for (k in seq_along(beta_layers)) {
      networks[[paste0("temporal_lag", used_lags[k])]] <-
        .ido_wrap(beta_layers[[k]], paste0("mlvar_temporal_lag", used_lags[k]),
                  TRUE)
    }
  }
  networks$contemporaneous <- .ido_wrap(theta, "mlvar_contemporaneous", FALSE)
  networks$between <- .ido_wrap(omega, "mlvar_between", FALSE)

  B <- fit$results$Beta
  rows <- do.call(rbind, lapply(seq_along(beta_layers), function(k) {
    grid <- expand.grid(outcome = vars, predictor = vars,
                        stringsAsFactors = FALSE)
    idx <- cbind(match(grid$outcome, vars), match(grid$predictor, vars))
    pull_cell <- function(slot) {
      a <- B[[slot]]
      if (is.null(a)) return(rep(NA_real_, nrow(grid)))
      if (length(dim(a)) == 3L) a <- a[, , k, drop = TRUE]
      as.numeric(a[idx])
    }
    data.frame(
      lag = used_lags[k], outcome = grid$outcome, predictor = grid$predictor,
      beta = as.numeric(beta_layers[[k]][idx]),
      se = pull_cell("SD"), t = NA_real_, p = pull_cell("P"),
      ci_lower = pull_cell("lower"), ci_upper = pull_cell("upper"),
      significant = {
        lo <- pull_cell("lower"); hi <- pull_cell("upper")
        !is.na(lo) & !is.na(hi) & (lo > 0 | hi < 0)
      }, stringsAsFactors = FALSE
    )
  }))
  if (length(beta_layers) == 1L) rows$lag <- NULL

  attr(networks, "coefs") <- rows
  attr(networks, "n_obs") <- fit$output$summaries$Observations %||%
    if (!is.null(fit$data)) nrow(fit$data) else NA_integer_
  attr(networks, "n_subjects") <- if (!is.null(fit$data) &&
                                        config$id %in% names(fit$data)) {
    length(unique(fit$data[[config$id]]))
  } else NA_integer_
  attr(networks, "lag") <- as.integer(lags)
  attr(networks, "temporal_matrices") <- beta_layers
  attr(networks, "standardize") <- config$scale
  attr(networks, "scale") <- config$scale
  attr(networks, "scaleWithin") <- config$scaleWithin
  attr(networks, "AR") <- config$AR
  attr(networks, "reference") <- fit
  attr(networks, "config") <- config
  attr(networks, "group_col") <- "network_type"
  class(networks) <- c("net_mlvar_reference", "net_mlvar", "cograph_group",
                       "netobject_group")
  networks
}

#' Tidy coefficients from a fitted mlvar model
#'
#' Generic accessor for the tidy coefficient table stored on a
#' [fit_mlvar()] result. Returns a `data.frame` with one row per
#' `(outcome, predictor)` pair and columns `outcome`, `predictor`,
#' `beta`, `se`, `t`, `p`, `ci_lower`, `ci_upper`, `significant`.
#'
#' Only the within-person (temporal) coefficients are tabulated —
#' these are the lagged fixed effects that populate `fit$temporal`.
#' The between-subjects effects that go into `fit$between` are handled
#' via the `D (I - Gamma)` transformation and are not exposed as a
#' separate tidy table.
#'
#' @param x A fitted model object — currently only `net_mlvar` is supported.
#' @param ... Unused.
#' @return A tidy `data.frame` of coefficient estimates.
#' @inherit fit_mlvar examples
#' @export
coefs <- function(x, ...) {
  UseMethod("coefs")
}

#' @rdname coefs
#' @export
coefs.net_mlvar <- function(x, ...) {
  attr(x, "coefs")
}

#' @rdname coefs
#' @export
coefs.default <- function(x, ...) {
  stop("No coefs() method for object of class '",
       class(x)[1], "'", call. = FALSE)
}

# ---- Internal helpers --------------------------------------------------

#' Safely pull named entries from a vector, NA for any missing name
#' @noRd
.mlvar_vec <- function(named, keys) {
  out <- rep(NA_real_, length(keys))
  hit <- match(keys, names(named))
  ok  <- !is.na(hit)
  out[ok] <- as.numeric(named[hit[ok]])
  out
}

#' Safely pull a column for named rows of a matrix, NA for any missing row
#'
#' `lme4` can drop a fixed effect in rank-deficient / collinear designs, leaving
#' its name out of `summary(fit)$coefficients`. Direct `mat[keys, col]` indexing
#' would then raise "subscript out of bounds"; this returns NA for the absent
#' rows so the coefficient table degrades gracefully.
#' @noRd
.mlvar_row <- function(mat, keys, col) {
  out <- rep(NA_real_, length(keys))
  hit <- match(keys, rownames(mat))
  ok  <- !is.na(hit)
  out[ok] <- as.numeric(mat[hit[ok], col])
  out
}

#' Drop rows with NA metadata and optionally grand-mean standardize
#' @noRd
.mlvar_prepare_data <- function(data, vars, id, day, beep, scale,
                                full_detrend = FALSE) {
  df <- as.data.frame(data)

  md_cols <- c(id,
               if (!is.null(day))  day,
               if (!is.null(beep)) beep)
  df <- df[stats::complete.cases(df[, md_cols, drop = FALSE]), , drop = FALSE]

  if (isTRUE(full_detrend)) {
    order_cols <- c(id, if (!is.null(day)) day, if (!is.null(beep)) beep)
    ord <- do.call(order, df[order_cols])
    inverse <- order(ord)
    ordered <- df[ord, , drop = FALSE]
    position <- stats::ave(seq_len(nrow(ordered)), ordered[[id]], FUN = seq_along)
    counts <- table(ordered[[id]])
    if (length(unique(as.integer(counts))) != 1L) {
      stop("`detrend = \"position\"` requires a balanced panel (the same ",
           "number of ordered observations per subject).", call. = FALSE)
    }
    for (v in vars) {
      ordered[[v]] <- stats::ave(ordered[[v]], position, FUN = function(x) {
        s <- stats::sd(x, na.rm = TRUE)
        if (!is.finite(s) || s == 0) x - mean(x, na.rm = TRUE) else
          (x - mean(x, na.rm = TRUE)) / s
      })
    }
    df <- ordered[inverse, , drop = FALSE]
  }

  if (isTRUE(scale)) {
    for (v in vars) {
      x <- as.numeric(df[[v]])
      sd_val <- stats::sd(x, na.rm = TRUE)
      if (is.na(sd_val) || sd_val == 0) {
        df[[v]] <- 0
      } else {
        df[[v]] <- (x - mean(x, na.rm = TRUE)) / sd_val
      }
    }
  }
  df
}

#' Beep-grid augmentation + within/between predictor construction
#'
#' Base-R implementation: build the full per-(id, day) consecutive-beep grid,
#' place the observed rows onto it with a `match()`-based join (NA elsewhere),
#' and order the augmented panel by (id, day, beep). The join copies each column
#' by position (`df[[v]][match]`), so integer inputs keep integer type and NA
#' fills stay `NA_integer_` -- important because base R's `mean()` uses two-pass
#' summation for doubles but a plain sum/n for integers, and that ~1e-14
#' difference otherwise amplifies through lmer into ~1e-10 coefficient diffs
#' against `mlVAR`. The within-group lag/centre/mean arithmetic then uses base
#' `ave()` for the same reason.
#' @noRd
.mlvar_augment_data <- function(data, vars, id, day, beep, lag,
                                scaleWithin = FALSE,
                                compare_to_lags = NULL,
                                true_means = NULL,
                                missing = c("omit", "fail")) {
  missing <- match.arg(missing)
  id_col   <- id
  day_col  <- if (is.null(day))  ".day"  else day
  beep_col <- if (is.null(beep)) ".beep" else beep

  df <- as.data.frame(data)
  if (is.null(day)) df[[day_col]] <- 1L
  if (is.null(beep)) {
    df[[beep_col]] <- stats::ave(seq_len(nrow(df)), df[[id_col]], df[[day_col]],
                                 FUN = seq_along)
  }

  idv <- df[[id_col]]; dayv <- df[[day_col]]; beepv <- df[[beep_col]]

  # Per-(id, day) beep range; the augmented grid is first:last (consecutive
  # integers), matching mlVAR's global seq() restricted to each block's range.
  gkey <- paste(idv, dayv, sep = "\r")
  first <- tapply(beepv, gkey, min, na.rm = TRUE)
  last  <- tapply(beepv, gkey, max, na.rm = TRUE)
  ud <- unique(data.frame(id = idv, day = dayv, stringsAsFactors = FALSE))
  udk <- paste(ud$id, ud$day, sep = "\r")
  grid <- do.call(rbind, lapply(seq_len(nrow(ud)), function(k) {
    b <- seq.int(first[[udk[k]]], last[[udk[k]]])
    data.frame(a = ud$id[k], b = ud$day[k], c = b, stringsAsFactors = FALSE)
  }))
  names(grid) <- c(id_col, day_col, beep_col)
  grid <- grid[order(grid[[id_col]], grid[[day_col]], grid[[beep_col]]), ,
               drop = FALSE]

  # Place observed values onto the grid by (id, day, beep), preserving types.
  m <- match(paste(grid[[id_col]], grid[[day_col]], grid[[beep_col]], sep = "\r"),
             paste(idv, dayv, beepv, sep = "\r"))
  augData <- grid
  for (col in setdiff(names(df), c(id_col, day_col, beep_col))) {
    augData[[col]] <- df[[col]][m]
  }
  rownames(augData) <- NULL

  true_match <- NULL
  if (!is.null(true_means)) {
    if (!is.data.frame(true_means) || !all(c(id, vars) %in% names(true_means))) {
      stop("`true_means` must be a data frame containing `id` and every ",
           "model variable.", call. = FALSE)
    }
    if (anyDuplicated(true_means[[id]])) {
      stop("`true_means` must have exactly one row per subject.", call. = FALSE)
    }
    if (any(!vapply(true_means[vars], is.numeric, logical(1)))) {
      stop("Every model-variable column in `true_means` must be numeric.",
           call. = FALSE)
    }
    true_match <- match(as.character(augData[[id_col]]),
                        as.character(true_means[[id]]))
    if (anyNA(true_match)) {
      stop("`true_means` is missing one or more fitted subjects.", call. = FALSE)
    }
  }

  predModel <- list()

  # Within-person centering of the lagged predictors. With scaleWithin the
  # centering also divides by the within-person SD (mlVAR's aveCenter(scale=TRUE)).
  center_fun <- if (isTRUE(scaleWithin)) {
    function(x) (x - mean(x, na.rm = TRUE)) / stats::sd(x, na.rm = TRUE)
  } else {
    function(x) x - mean(x, na.rm = TRUE)
  }

  # Within (lagged, person-centered) predictors. Extra compare-to lags are
  # constructed and included in complete-row filtering, but are not fitted.
  fit_lags <- sort(unique(as.integer(lag)))
  all_lags <- sort(unique(c(fit_lags, as.integer(compare_to_lags))))
  compare_ids <- character()
  for (lag_i in all_lags) {
    for (v in vars) {
      p_id <- paste0("L", lag_i, "_", v)
      augData[[p_id]] <- stats::ave(
        augData[[v]], augData[[id_col]], augData[[day_col]],
        FUN = function(x) .mlvar_aveLag(x, lag_i)
      )
      if (is.null(true_means)) {
        augData[[p_id]] <- stats::ave(
          augData[[p_id]], augData[[id_col]], FUN = center_fun
        )
      } else {
        # mlVAR's trueMeans branch subtracts the supplied person mean rather
        # than the observed lag mean. If within scaling is requested it then
        # applies aveScaleNoCenter, preserving the centered series' own mean.
        augData[[p_id]] <- augData[[p_id]] - true_means[[v]][true_match]
        if (isTRUE(scaleWithin)) {
          augData[[p_id]] <- stats::ave(
            augData[[p_id]], augData[[id_col]], FUN = function(x) {
              m <- mean(x, na.rm = TRUE)
              (x - m) / stats::sd(x, na.rm = TRUE) + m
            }
          )
        }
      }
      if (lag_i %in% fit_lags) {
        predModel[[length(predModel) + 1L]] <- list(
          dep = vars, pred = v, id = p_id, type = "within", lag = lag_i
        )
      } else {
        compare_ids <- c(compare_ids, p_id)
      }
    }
  }

  # Between (person-mean) predictors
  for (v in vars) {
    p_id <- paste0("PM_", v)
    if (is.null(true_means)) {
      augData[[p_id]] <- stats::ave(
        augData[[v]], augData[[id_col]],
        FUN = function(x) mean(x, na.rm = TRUE)
      )
    } else {
      augData[[p_id]] <- true_means[[v]][true_match]
    }
    predModel[[length(predModel) + 1L]] <- list(
      dep = vars, pred = v, id = p_id, type = "between", lag = NA_integer_
    )
  }

  # With scaleWithin, the outcome variables are within-person scaled around the
  # person mean (mlVAR's aveScaleNoCenter: center, /SD, then re-add the mean).
  if (isTRUE(scaleWithin)) {
    for (v in vars) {
      augData[[v]] <- stats::ave(
        augData[[v]], augData[[id_col]],
        FUN = function(x) {
          m <- mean(x, na.rm = TRUE)
          (x - m) / stats::sd(x, na.rm = TRUE) + m
        }
      )
    }
  }

  involved <- unique(c(vars, vapply(predModel, `[[`, character(1), "id"),
                       compare_ids))
  augData <- augData[, c(involved, id_col, day_col, beep_col), drop = FALSE]
  incomplete <- !stats::complete.cases(augData[, involved, drop = FALSE])
  if (any(incomplete) && identical(missing, "fail")) {
    stop(sum(incomplete), " incomplete model row(s) found with ",
         "`missing = \"fail\"`.", call. = FALSE)
  }
  if (any(incomplete)) augData <- augData[!incomplete, , drop = FALSE]
  rownames(augData) <- NULL

  list(data = augData, predModel = predModel)
}

#' Fit d outcome-specific lmer models and assemble the three networks
#'
#' Matches `mlVAR:::lmer_mlVAR` with `temporal = "fixed"`,
#' `contemporaneous = "fixed"`. For each outcome k fits
#' `outcome_k ~ L1_v1 + ... + L1_vd + PM_v_{-k} + (1 | id)` with
#' `REML = FALSE`, then assembles Beta, Gamma, mu_SD, residuals.
#' @noRd
.mlvar_estimate_lmer <- function(augData, predModel, vars, id, AR = FALSE,
                                 temporal = c("fixed", "correlated",
                                              "orthogonal"),
                                 contemporaneous = c("fixed", "correlated",
                                                     "orthogonal", "unique"),
                                 nCores = 1L) {
  temporal <- match.arg(temporal)
  contemporaneous <- match.arg(contemporaneous)
  d <- length(vars)
  n_obs <- nrow(augData)

  within_model <- Filter(function(m) m$type == "within", predModel)
  between_model <- Filter(function(m) m$type == "between", predModel)
  lags <- sort(unique(vapply(within_model, `[[`, integer(1), "lag")))
  B_layers <- stats::setNames(lapply(lags, function(z) {
    matrix(0, d, d, dimnames = list(vars, vars))
  }), paste0("lag", lags))
  Gamma         <- matrix(0, d, d, dimnames = list(vars, vars))
  mu_SD         <- stats::setNames(numeric(d), vars)
  sigma_vec     <- stats::setNames(numeric(d), vars)
  residuals_mat <- matrix(NA_real_, n_obs, d, dimnames = list(NULL, vars))

  within_ids <- vapply(within_model, `[[`, character(1), "id")
  within_vars <- vapply(within_model, `[[`, character(1), "pred")
  within_lags <- vapply(within_model, `[[`, integer(1), "lag")
  between_ids <- vapply(between_model, `[[`, character(1), "id")
  between_vars <- vapply(between_model, `[[`, character(1), "pred")
  var_to_between <- stats::setNames(between_ids, vars)

  z975 <- stats::qnorm(0.975)

  # Tidy coefs: one row per (outcome, predictor) pair — fills d * d rows.
  # Faster and cleaner than growing a list of per-outcome data.frames and
  # `do.call(rbind, ...)` at the end.
  n_coef_rows <- d * d * length(lags)
  coefs_tidy <- data.frame(
    lag         = rep(lags, each = d * d),
    outcome     = rep(rep(vars, each = d), times = length(lags)),
    predictor   = rep(vars, times = d * length(lags)),
    beta        = numeric(n_coef_rows),
    se          = numeric(n_coef_rows),
    t           = numeric(n_coef_rows),
    p           = numeric(n_coef_rows),
    ci_lower    = numeric(n_coef_rows),
    ci_upper    = numeric(n_coef_rows),
    significant = logical(n_coef_rows),
    stringsAsFactors = FALSE
  )

  fitted_models <- vector("list", d)
  names(fitted_models) <- vars

  specs <- lapply(seq_len(d), function(k) {
    outcome <- vars[k]
    within_preds <- if (isTRUE(AR)) {
      within_ids[within_vars == outcome]
    } else within_ids
    fixed_preds <- c(within_preds, var_to_between[-k])
    random_term <- if (temporal == "fixed") {
      paste0("(1 | ", id, ")")
    } else {
      paste0("(", paste(within_preds, collapse = " + "), " ",
             if (temporal == "orthogonal") "||" else "|", " ", id, ")")
    }
    list(
      outcome = outcome,
      within_preds = within_preds,
      between_keys = var_to_between[vars[-k]],
      formula = stats::as.formula(paste0(
        outcome, " ~ ", paste(fixed_preds, collapse = " + "), " + ",
        random_term
      ))
    )
  })
  fit_one <- function(k) {
    suppressMessages(suppressWarnings(
      lme4::lmer(specs[[k]]$formula, data = augData, REML = FALSE)
    ))
  }
  if (nCores > 1L && d > 1L) {
    workers <- min(as.integer(nCores), d)
    if (.Platform$OS.type == "windows") {
      cl <- parallel::makePSOCKcluster(workers)
      on.exit(parallel::stopCluster(cl), add = TRUE)
      parallel::clusterEvalQ(cl, library(lme4))
      fitted_models <- parallel::parLapply(cl, seq_len(d), fit_one)
    } else {
      fitted_models <- parallel::mclapply(seq_len(d), fit_one,
                                          mc.cores = workers,
                                          mc.preschedule = TRUE)
    }
    names(fitted_models) <- vars
  } else {
    fitted_models <- lapply(seq_len(d), fit_one)
    names(fitted_models) <- vars
  }

  for (k in seq_len(d)) {
    outcome <- specs[[k]]$outcome
    # AR = TRUE: each outcome regresses on its OWN lag only (mlVAR AR = TRUE
    # gives a diagonal temporal matrix); otherwise on all lagged predictors.
    # Own PM excluded — matches mlVAR's `getModel` filter on `dep == outcome`.
    within_preds <- specs[[k]]$within_preds
    fit <- fitted_models[[k]]

    # Convergence diagnostics — warn but don't stop (matches mlVAR behaviour)
    if (lme4::isSingular(fit)) {
      warning(sprintf(
        "Model for '%s': singular fit (random-effects variance near zero).",
        outcome
      ), call. = FALSE)
    }
    conv_msgs <- fit@optinfo$conv$lme4$messages
    if (length(conv_msgs) > 0L) {
      warning(sprintf(
        "Model for '%s': %s", outcome, paste(conv_msgs, collapse = "; ")
      ), call. = FALSE)
    }

    fe <- lme4::fixef(fit)
    # Defensive name matching: a rank-deficient lmer fit can silently drop a
    # predictor, so pull by name and warn rather than mis-align or error.
    within_keys <- within_ids
    between_keys <- specs[[k]]$between_keys
    dropped <- setdiff(c(within_preds, between_keys), names(fe))  # vs fitted set
    if (length(dropped) > 0L) {
      warning(sprintf(
        "Model for '%s' dropped predictor(s) %s (rank-deficient design); ",
        outcome, paste(dropped, collapse = ", ")),
        "filling the affected coefficients with NA.", call. = FALSE)
    }
    B_row <- .mlvar_vec(fe, within_keys)
    if (isTRUE(AR)) B_row[within_vars != outcome] <- 0
    for (ell in seq_along(lags)) {
      take <- within_lags == lags[ell]
      B_layers[[ell]][k, ] <- B_row[take]
    }
    Gamma[k, -k]  <- .mlvar_vec(fe, between_keys)

    vc <- lme4::VarCorr(fit)
    ri_var <- as.numeric(vc[[id]][1, 1])
    mu_SD[k] <- if (!is.na(ri_var) && ri_var > 0) sqrt(ri_var) else 0

    sigma_vec[k] <- stats::sigma(fit)

    # Align residuals to augData row order (lmer drops any NA rows)
    res <- stats::residuals(fit)
    row_names <- rownames(augData)
    if (!is.null(row_names) && !is.null(names(res))) {
      residuals_mat[, k] <- res[match(row_names, names(res))]
    } else {
      residuals_mat[, k] <- res
    }

    sfe    <- summary(fit)$coefficients
    beta_k <- B_row                               # AR off-diagonals already 0
    se_k   <- .mlvar_row(sfe, within_keys, "Std. Error")
    t_k    <- .mlvar_row(sfe, within_keys, "t value")
    p_k    <- 2 * (1 - stats::pnorm(abs(t_k)))

    for (ell in seq_along(lags)) {
      take <- which(within_lags == lags[ell])
      rows <- (ell - 1L) * d * d + ((k - 1L) * d + seq_len(d))
      coefs_tidy$beta[rows]        <- beta_k[take]
      coefs_tidy$se[rows]          <- se_k[take]
      coefs_tidy$t[rows]           <- t_k[take]
      coefs_tidy$p[rows]           <- p_k[take]
      coefs_tidy$ci_lower[rows]    <- beta_k[take] - z975 * se_k[take]
      coefs_tidy$ci_upper[rows]    <- beta_k[take] + z975 * se_k[take]
      coefs_tidy$significant[rows] <- !is.na(p_k[take]) & p_k[take] < 0.05
    }
  }

  subject_temporal <- NULL
  if (temporal != "fixed") {
    subject_ids <- rownames(lme4::ranef(fitted_models[[1L]])[[id]])
    subject_temporal <- stats::setNames(vector("list", length(subject_ids)),
                                        subject_ids)
    ran <- lapply(fitted_models, function(fit) lme4::ranef(fit)[[id]])
    for (s in seq_along(subject_ids)) {
      layers <- lapply(seq_along(lags), function(ell) {
        mat <- B_layers[[ell]]
        take <- within_lags == lags[ell]
        keys <- within_ids[take]
        for (k in seq_len(d)) {
          vals <- .mlvar_vec(unlist(ran[[k]][s, , drop = TRUE]), keys)
          vals[is.na(vals)] <- 0
          mat[k, ] <- mat[k, ] + vals
        }
        mat
      })
      names(layers) <- names(B_layers)
      subject_temporal[[s]] <- if (length(layers) == 1L) layers[[1L]] else layers
    }
  }

  theta <- .mlvar_estimate_contemporaneous(
    residuals_mat, augData[[id]], vars, sigma_vec, contemporaneous, id
  )
  between         <- .mlvar_compute_between_from_gamma(Gamma, mu_SD, vars)

  if (length(lags) == 1L) {
    B_out <- B_layers[[1L]]
    coefs_tidy$lag <- NULL
  } else B_out <- B_layers
  list(temporal = list(B = B_out, coefs = coefs_tidy,
                       residuals = residuals_mat, subjects = subject_temporal),
       contemporaneous = theta$group,
       contemporaneous_subjects = theta$subjects,
       between = between,
       models = fitted_models)
}

#' Separate person-specific least-squares mlVAR models
#' @noRd
.mlvar_estimate_unique <- function(augData, predModel, vars, id, AR = FALSE,
                                   contemporaneous = c("fixed", "unique",
                                                       "correlated",
                                                       "orthogonal")) {
  contemporaneous <- match.arg(contemporaneous)
  within_model <- Filter(function(m) m$type == "within", predModel)
  within_ids <- vapply(within_model, `[[`, character(1), "id")
  within_vars <- vapply(within_model, `[[`, character(1), "pred")
  within_lags <- vapply(within_model, `[[`, integer(1), "lag")
  lags <- sort(unique(within_lags))
  d <- length(vars)
  ids <- unique(augData[[id]])
  subject_layers <- stats::setNames(vector("list", length(ids)),
                                    as.character(ids))
  subject_intercepts <- matrix(NA_real_, length(ids), d,
                               dimnames = list(as.character(ids), vars))
  residuals_mat <- matrix(NA_real_, nrow(augData), d,
                          dimnames = list(NULL, vars))
  subject_betas <- array(NA_real_, c(length(ids), d, d, length(lags)),
                         dimnames = list(as.character(ids), vars, vars,
                                         paste0("lag", lags)))

  for (s in seq_along(ids)) {
    rows <- which(augData[[id]] == ids[s])
    dat <- augData[rows, , drop = FALSE]
    layers <- stats::setNames(lapply(lags, function(z) {
      matrix(0, d, d, dimnames = list(vars, vars))
    }), paste0("lag", lags))
    for (k in seq_len(d)) {
      keep <- if (isTRUE(AR)) within_vars == vars[k] else rep(TRUE, length(within_ids))
      form <- stats::reformulate(within_ids[keep], response = vars[k])
      fit <- stats::lm(form, data = dat)
      cf <- stats::coef(fit)
      subject_intercepts[s, k] <- unname(cf["(Intercept)"])
      residuals_mat[rows, k] <- stats::residuals(fit)
      for (ell in seq_along(lags)) {
        take <- within_lags == lags[ell]
        vals <- .mlvar_vec(cf, within_ids[take])
        if (isTRUE(AR)) vals[within_vars[take] != vars[k]] <- 0
        layers[[ell]][k, ] <- vals
        subject_betas[s, k, , ell] <- vals
      }
    }
    subject_layers[[s]] <- if (length(layers) == 1L) layers[[1L]] else layers
  }

  B_layers <- lapply(seq_along(lags), function(ell) {
    apply(subject_betas[, , , ell, drop = FALSE], c(2, 3), mean, na.rm = TRUE)
  })
  names(B_layers) <- paste0("lag", lags)
  B_layers <- lapply(B_layers, function(x) {
    dimnames(x) <- list(vars, vars); x
  })

  rows <- vector("list", length(lags))
  z975 <- stats::qnorm(.975)
  for (ell in seq_along(lags)) {
    grid <- expand.grid(outcome = vars, predictor = vars,
                        stringsAsFactors = FALSE)
    idx <- cbind(match(grid$outcome, vars), match(grid$predictor, vars))
    se <- apply(subject_betas[, , , ell, drop = FALSE], c(2, 3),
                stats::sd, na.rm = TRUE) / sqrt(length(ids))
    beta <- B_layers[[ell]]
    tval <- beta / se
    pval <- 2 * (1 - stats::pnorm(abs(tval)))
    rows[[ell]] <- data.frame(
      lag = lags[ell], outcome = grid$outcome, predictor = grid$predictor,
      beta = beta[idx], se = se[idx], t = tval[idx], p = pval[idx],
      ci_lower = beta[idx] - z975 * se[idx],
      ci_upper = beta[idx] + z975 * se[idx],
      significant = !is.na(pval[idx]) & pval[idx] < .05,
      stringsAsFactors = FALSE
    )
  }
  coefs_tidy <- do.call(rbind, rows)
  rownames(coefs_tidy) <- NULL
  if (length(lags) == 1L) coefs_tidy$lag <- NULL

  mean_cov <- stats::cov(subject_intercepts, use = "pairwise.complete.obs")
  between <- if (anyNA(mean_cov)) {
    warning("Between-subjects network is not estimable; returning zeros.",
            call. = FALSE)
    matrix(0, d, d, dimnames = list(vars, vars))
  } else {
    ans <- .ido_cor2pcor(.mlvar_force_positive(mean_cov))
    diag(ans) <- 0; dimnames(ans) <- list(vars, vars); ans
  }
  sigma_vec <- apply(residuals_mat, 2L, stats::sd, na.rm = TRUE)
  theta <- .mlvar_estimate_contemporaneous(
    residuals_mat, augData[[id]], vars, sigma_vec, contemporaneous, id
  )
  list(temporal = list(B = if (length(lags) == 1L) B_layers[[1L]] else B_layers,
                       coefs = coefs_tidy, residuals = residuals_mat,
                       subjects = subject_layers),
       contemporaneous = theta$group,
       contemporaneous_subjects = theta$subjects,
       between = between)
}

#' Group and person-specific contemporaneous networks
#' @noRd
.mlvar_estimate_contemporaneous <- function(residuals_mat, subject, vars,
                                             sigma_vec, structure, id_name) {
  if (structure == "fixed") {
    return(list(group = .mlvar_contemporaneous_fixed(residuals_mat, sigma_vec,
                                                     vars), subjects = NULL))
  }
  ids <- unique(subject)
  if (structure == "unique") {
    covs <- lapply(ids, function(z) {
      x <- residuals_mat[subject == z, , drop = FALSE]
      stats::cov(x, use = "pairwise.complete.obs")
    })
    valid <- vapply(covs, function(x) is.matrix(x) && !anyNA(x), logical(1))
    group_cov <- if (any(valid)) Reduce(`+`, covs[valid]) / sum(valid) else
      matrix(NA_real_, length(vars), length(vars))
    group <- if (anyNA(group_cov)) {
      matrix(0, length(vars), length(vars), dimnames = list(vars, vars))
    } else {
      ans <- .ido_cor2pcor(.mlvar_force_positive(group_cov))
      diag(ans) <- 0; dimnames(ans) <- list(vars, vars); ans
    }
    subjects <- stats::setNames(lapply(covs, function(x) {
      if (anyNA(x)) return(matrix(NA_real_, length(vars), length(vars),
                                  dimnames = list(vars, vars)))
      ans <- .ido_cor2pcor(.mlvar_force_positive(x)); diag(ans) <- 0
      dimnames(ans) <- list(vars, vars); ans
    }), as.character(ids))
    return(list(group = group, subjects = subjects))
  }

  resid_df <- as.data.frame(residuals_mat)
  resid_df[[id_name]] <- subject
  d <- length(vars)
  gamma <- matrix(0, d, d, dimnames = list(vars, vars))
  fits <- vector("list", d)
  for (k in seq_len(d)) {
    others <- vars[-k]
    random <- paste0("(0 + ", paste(others, collapse = " + "), " ",
                     if (structure == "orthogonal") "||" else "|", " ",
                     id_name, ")")
    form <- stats::as.formula(paste(vars[k], "~ 0 +",
                                    paste(others, collapse = " + "), "+",
                                    random))
    fits[[k]] <- suppressMessages(suppressWarnings(
      lme4::lmer(form, data = resid_df, REML = FALSE)
    ))
    gamma[k, -k] <- .mlvar_vec(lme4::fixef(fits[[k]]), others)
  }
  D <- diag(1 / vapply(fits, function(x) stats::sigma(x)^2, numeric(1)))
  precision <- .mlvar_force_positive(D %*% (diag(d) - gamma))
  group <- .ido_cor2pcor(.ido_pseudoinverse(precision))
  diag(group) <- 0; dimnames(group) <- list(vars, vars)
  ran <- lapply(fits, function(x) lme4::ranef(x)[[id_name]])
  subject_ids <- rownames(ran[[1L]])
  subjects <- stats::setNames(lapply(seq_along(subject_ids), function(s) {
    gs <- gamma
    for (k in seq_len(d)) {
      vals <- .mlvar_vec(unlist(ran[[k]][s, , drop = TRUE]), vars[-k])
      vals[is.na(vals)] <- 0
      gs[k, -k] <- gs[k, -k] + vals
    }
    prec <- .mlvar_force_positive(D %*% (diag(d) - gs))
    ans <- .ido_cor2pcor(.ido_pseudoinverse(prec)); diag(ans) <- 0
    dimnames(ans) <- list(vars, vars); ans
  }), subject_ids)
  list(group = group, subjects = subjects)
}

#' Within-group lag (matches mlVAR:::aveLag)
#'
#' Uses a logical `NA` (not `NA_real_`) for the prepended entries so that
#' integer input columns retain integer type. Preserving the integer type
#' is critical because base R's `mean()` uses a two-pass summation
#' correction for numeric input but a simple sum/n for integer input — the
#' two paths drift by ~1.4e-14, which then amplifies through lmer into
#' ~1e-10 coefficient diffs against mlVAR's integer-typed pipeline.
#' @noRd
.mlvar_aveLag <- function(x, lag = 1L) {
  n <- length(x)
  if (lag >= n) return(rep(NA, n))
  c(rep(NA, lag), x[seq_len(n - lag)])
}

#' Force a symmetric matrix to be positive-definite — byte-for-byte replica
#' of `mlVAR:::forcePositive`.
#'
#' Note the scalar-recycling quirk in the upstream implementation. In
#' `x - (diag(n) * min_ev - 0.001)`, the `0.001` scalar is subtracted from
#' every element of the diagonal matrix — so the final operation adds
#' `|min_ev|` to the diagonal *and* `+0.001` to every off-diagonal element.
#' This looks unintentional upstream but has to be replicated exactly for
#' equivalence with `mlVAR::mlVAR()`.
#' @noRd
.mlvar_force_positive <- function(x) {
  x <- (x + t(x)) / 2
  ev <- eigen(x, symmetric = TRUE, only.values = TRUE)$values
  if (any(ev < 0)) {
    x - (diag(nrow(x)) * min(ev) - 0.001)
  } else {
    x
  }
}

#' Between-subjects partial correlation from Gamma + mu_SD
#'
#' Matches mlVAR's Omega_mu branch:
#'   `D = diag(1 / mu_SD^2)`
#'   `inv = forcePositive(D (I - Gamma))`
#'   `cov = .ido_pseudoinverse(inv)`
#'   `pcor = .ido_cor2pcor(cov)`
#' @noRd
.mlvar_compute_between_from_gamma <- function(Gamma, mu_SD, vars) {
  d <- length(vars)
  if (any(mu_SD == 0)) {
    warning("Between-subjects network not estimable: a random-intercept SD is ",
            "0 (no between-person variance). Returning a zero matrix by ",
            "convention (mlVAR returns NA here).", call. = FALSE)
    return(matrix(0, d, d, dimnames = list(vars, vars)))
  }

  D <- diag(1 / mu_SD^2)
  inv <- D %*% (diag(d) - Gamma)
  inv <- (inv + t(inv)) / 2
  inv <- .mlvar_force_positive(inv)

  mu_cov <- .ido_pseudoinverse(inv)
  pcor <- .ido_cor2pcor(mu_cov)
  diag(pcor) <- 0
  rownames(pcor) <- colnames(pcor) <- vars
  pcor
}

#' Contemporaneous partial correlation via mlVAR's "fixed" path
#'
#' Replicates `mlVAR:::lmer_mlVAR` Theta assembly for
#' `contemporaneous = "fixed"`: rescale the residual correlation by the
#' per-outcome lmer residual SDs and take `cor2pcor` directly. No
#' EBIC-GLASSO regularization. Note `cor2pcor` is scale-invariant, so the
#' `D %*% . %*% D` rescaling does not affect the pcor output — it is kept
#' only for parity with mlVAR's `cov`/`prec` slots.
#' @noRd
.mlvar_contemporaneous_fixed <- function(residuals_mat, sigma_vec, vars) {
  d <- length(vars)
  R <- stats::cor(residuals_mat, use = "pairwise.complete.obs")
  if (any(is.na(R))) {
    warning("Contemporaneous network not estimable: residual correlations ",
            "contain NA. Returning a zero matrix by convention.",
            call. = FALSE)
    return(matrix(0, d, d, dimnames = list(vars, vars)))
  }
  D <- diag(sigma_vec)
  Theta_cov <- D %*% stats::cov2cor(R) %*% D
  pcor <- .ido_cor2pcor(Theta_cov)
  diag(pcor) <- 0
  rownames(pcor) <- colnames(pcor) <- vars
  pcor
}


# ---- S3 methods --------------------------------------------------------

#' Print method for net_mlvar
#'
#' @param x A `net_mlvar` object returned by [fit_mlvar()].
#' @param digits Number of digits used for printed network matrices.
#' @param ... Unused; present for S3 consistency.
#' @return Invisibly returns `x`.
#' @inherit fit_mlvar examples
#' @export
print.net_mlvar <- function(x, digits = 2, ...) {
  coef_df <- attr(x, "coefs")
  temporal_name <- grep("^temporal($|_)", names(x), value = TRUE)[1L]
  d <- nrow(x[[temporal_name]]$weights)
  n_sig <- sum(coef_df$significant, na.rm = TRUE)
  n_tot <- nrow(coef_df)

  cat(sprintf("mlVAR result: %d subjects, %d observations, %d variables (lags %s)\n",
              attr(x, "n_subjects"),
              attr(x, "n_obs"),
              d,
              paste(attr(x, "lag"), collapse = ", ")))
  cat(sprintf("  Temporal edges significant at p<0.05: %d / %d\n", n_sig, n_tot))
  .ido_print_networks(x, digits = digits)
  cat("\n  plot(x) | plot(x, layer = \"temporal\") | plot(x, layer = \"between\")",
      "\n  edges(x) | nodes(x) | summary(x) | coefs(x) | matrices(x)\n")
  invisible(x)
}

#' Summary method for net_mlvar
#'
#' @param object A `net_mlvar` object returned by [fit_mlvar()].
#' @param ... Unused; present for S3 consistency.
#' @return A tidy `data.frame` of per-network metrics (one row per network:
#'   `temporal`, `contemporaneous`, `between`). Use `coefs(object)` for the
#'   fixed-effect coefficient table, `edges(object)` for the edge list, and
#'   `nodes(object)` for node strengths.
#' @inherit fit_mlvar examples
#' @export
summary.net_mlvar <- function(object, ...) {
  .tidy_over_group(as_netobject(object), .net_metrics)
}

Try the idiographic package in your browser

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

idiographic documentation built on Aug. 4, 2026, 1:07 a.m.