R/lav_sem_js.R

Defines functions lav_sem_js_eqs_pass lav_sem_js_lambda_mat lav_sem_js_stage1_samp lav_sem_js_eq_plans lav_sem_js_weights lav_sem_js_theta lav_sem_js_theta_spearman_masked lav_sem_js_contam lav_sem_js_check_block lav_sem_js_check lav_sem_js_vcov lav_sem_js_estimate lav_sem_js_internal

# James-Stein type (conditional-expectation) estimation
#
# Burghgraeve, E., De Neve, J., & Rosseel, Y. (2021). Estimating structural
# equation models using James-Stein type shrinkage estimators. Psychometrika,
# 86(1), 96-130.
#
# In every (measurement or structural) equation, the latent variables are
# replaced by an estimate of their conditional expectation given observed
# proxies: the scaling indicators (estimator = "JS"), or reliability-weighted
# aggregates of the indicators (estimator = "JSA"); the equation is then
# estimated by least squares. Everything is computed from the sample moments
# ('attenuation-corrected' least squares): regressing on the estimated
# conditional expectations is algebraically identical to correcting the
# normal equations for the measurement-error variance of the proxies.
#
# The equation decomposition (dependent variables, scaling-indicator
# substitution) is shared with the (MI)IV estimator (lav_model_find_iv);
# the two estimators differ only in how each equation is estimated (2SLS
# with instruments versus least squares on conditional expectations).
# The undirected (variance/covariance) parameters are estimated by the
# same second stage as the IV estimator (lav_sem_miiv_varcov).
#
# YR/EB 08 Feb 2023: first version (lav_cfa_jamesstein.R), cfa only
# YR    18 Jul 2026: rewritten in moment form: structural models, observed
#                    covariates, mean structure; stage 2 via the IV
#                    machinery; closed-form (alphamax) aggregation weights

# internal function to be used inside lav_optim_noniter
# return 'x', the estimated vector of free parameters
lav_sem_js_internal <- function(lavmodel = NULL, lavh1 = NULL,
                                lavsamplestats = NULL,
                                lavpartable = NULL,
                                lavdata = NULL, lavoptions = NULL) {
  lavpta <- lav_pt_attributes(lavpartable)
  lavpartable <- lav_pt_set_cache(lavpartable, lavpta)

  # aggregated (JSA) or scaling (JS)?
  aggregated <- identical(lavoptions$estimator, "JSA")

  # model-class checks
  lav_sem_js_check(
    lavmodel = lavmodel, lavpartable = lavpartable, lavpta = lavpta,
    lavdata = lavdata, lavoptions = lavoptions, aggregated = aggregated
  )

  # equations: reuse the (MI)IV decomposition (each dependent variable, with
  # the latent variables replaced by their scaling indicators); the
  # instrument sets (eq$iv/eq$miiv) are ignored here
  eqs <- lav_model_find_iv(lavmodel = lavmodel, lavpartable = lavpartable)

  # directed versus undirected (free) parameters
  undirected_idx <- which(lavpartable$free > 0L &
    !duplicated(lavpartable$free) & # if ceq.simple
    lavpartable$op == "~~")
  directed_idx <- which(lavpartable$free > 0L &
    !duplicated(lavpartable$free) & # if ceq.simple
    lavpartable$op %in% c("=~", "~", "~1"))
  free_directed_idx <- unique(lavpartable$free[directed_idx])
  free_undirected_idx <- unique(lavpartable$free[undirected_idx])

  # run the (complete) estimation map at the sample moments
  x <- lav_sem_js_estimate(
    vec = NULL, eqs = eqs,
    lavmodel = lavmodel, lavpartable = lavpartable,
    lavsamplestats = lavsamplestats, lavh1 = lavh1,
    lavoptions = lavoptions,
    free_directed_idx = free_directed_idx,
    free_undirected_idx = free_undirected_idx,
    aggregated = aggregated
  )
  eqs <- attr(x, "eqs")
  x <- as.numeric(x)

  # apply bounds (if any)
  if (!is.null(lavpartable$lower)) {
    lower_x <- lavpartable$lower[lavpartable$free > 0 &
      !duplicated(lavpartable$free)]
    too_small_idx <- which(x < lower_x)
    if (length(too_small_idx) > 0L) {
      x[too_small_idx] <- lower_x[too_small_idx]
    }
  }
  if (!is.null(lavpartable$upper)) {
    upper_x <- lavpartable$upper[lavpartable$free > 0 &
      !duplicated(lavpartable$free)]
    too_large_idx <- which(x > upper_x)
    if (length(too_large_idx) > 0L) {
      x[too_large_idx] <- upper_x[too_large_idx]
    }
  }

  attr(x, "eqs") <- eqs
  x
}


# the COMPLETE estimation map, as a function of the sample moments: stage 1
# (directed parameters), stage 2 (variances/covariances) and the joint mean
# solve. When 'vec' is NULL, the lavh1 moments are used (the point
# estimates); when 'vec' contains a stacked moment vector (as produced by
# lav_implied_to_vec), the same map is evaluated at those moments -- this
# is the function that is differentiated (numerically) for the standard
# errors, so that the uncertainty of ALL ingredients (the Spearman
# reliability estimates, the aggregation weights, the pooled solves, the
# stage-2 weights and the mean solve) is propagated automatically
lav_sem_js_estimate <- function(vec = NULL, eqs = NULL,
                                lavmodel = NULL, lavpartable = NULL,
                                lavsamplestats = NULL, lavh1 = NULL,
                                lavoptions = NULL,
                                free_directed_idx = NULL,
                                free_undirected_idx = NULL,
                                aggregated = FALSE,
                                record = FALSE) {
  samplestats <- !is.null(vec)
  if (samplestats) {
    implied <- lav_vec_to_implied(vec, lavmodel = lavmodel)
  } else {
    implied <- lavh1$implied
  }

  # initial parameter vector
  x <- lav_model_get_parameters(lavmodel)

  ########################################
  # first stage: directed parameters     #
  ########################################
  eqs_out <- eqs
  js_trace <- NULL
  if (length(free_directed_idx) > 0L) {
    theta1 <- lav_sem_js_stage1_samp(
      x = vec, samplestats = samplestats, eqs = eqs,
      lavmodel = lavmodel, lavpartable = lavpartable,
      lavsamplestats = lavsamplestats, lavh1 = lavh1,
      lavoptions = lavoptions,
      free_directed_idx = free_directed_idx,
      aggregated = aggregated,
      record = record
    )
    eqs_out <- attr(theta1, "eqs")
    js_trace <- attr(theta1, "js_trace")
    x[free_directed_idx] <- as.numeric(theta1)
  }

  #######################################
  # second stage: undirected parameters #
  #######################################
  js_varcov_method <- toupper(lavoptions$estimator.args[["js_varcov_method"]])
  if (length(js_varcov_method) == 0L) {
    js_varcov_method <- "RLS"
  }
  if (length(free_undirected_idx) > 0L && js_varcov_method != "NONE") {
    lavmodel_tmp <- lav_model_set_parameters(lavmodel = lavmodel, x = x)
    delta_list <- lav_sem_miiv_delta(lavmodel_tmp)
    if (lavmodel@nblocks == 1L) {
      theta2 <- lav_sem_miiv_varcov_block(
        b = 1L, fu = free_undirected_idx, delta_b = delta_list[[1L]],
        implied = implied, lavmodel = lavmodel, x = x,
        iv_varcov_method = js_varcov_method, return_h = FALSE
      )
      x[free_undirected_idx] <- as.numeric(theta2)
    } else {
      # multiple groups: each group's moments contribute its own block
      # (as for the IV estimator; cross-group constraints among the
      # variances, if any, are handled per block)
      pt_block <- if (!is.null(lavpartable$block)) {
        lavpartable$block
      } else {
        rep(1L, length(lavpartable$op))
      }
      for (b in seq_len(lavmodel@nblocks)) {
        fu_b <- unique(lavpartable$free[lavpartable$op == "~~" &
          lavpartable$free > 0L & pt_block == b])
        fu_b <- fu_b[fu_b %in% free_undirected_idx]
        if (length(fu_b) == 0L) {
          next
        }
        tb <- lav_sem_miiv_varcov_block(
          b = b, fu = fu_b, delta_b = delta_list[[b]],
          implied = implied, lavmodel = lavmodel, x = x,
          iv_varcov_method = js_varcov_method, return_h = FALSE
        )
        x[fu_b] <- as.numeric(tb)
      }
    }
  } else if (length(free_undirected_idx) > 0L) {
    x[free_undirected_idx] <- as.numeric(NA)
  }

  # mean structure: by default, re-estimate all free mean parameters
  # (observed intercepts and latent means) jointly by GLS, exactly as the IV
  # estimator does; this also provides the means of the exogenous latent
  # variables (which have no equation of their own)
  js_mean_structure <- tolower(lavoptions$estimator.args[["js_mean_structure"]])
  if (length(js_mean_structure) == 0L) {
    js_mean_structure <- "wls"
  }
  if (lavmodel@meanstructure && identical(js_mean_structure, "wls")) {
    free_mean_idx <- unique(lavpartable$free[lavpartable$op == "~1" &
      lavpartable$free > 0L & !duplicated(lavpartable$free)])
    if (length(free_mean_idx) > 0L) {
      x[free_mean_idx] <- lav_sem_miiv_mean_wls(
        lavmodel = lavmodel, lavsamplestats = lavsamplestats, x = x,
        free_mean_idx = free_mean_idx,
        sample_mean = implied$mean
      )
    }
  }

  attr(x, "eqs") <- eqs_out
  if (record) {
    attr(x, "js_trace") <- js_trace
  }
  x
}


# VCOV for the free parameters: the delta method over the sample moments.
# The full estimation map theta(s) is differentiated with respect to the
# stacked moment vector s = [mean, vech(cov)], and sandwiched with the
# asymptotic covariance of the sample moments:
#
#   vcov = J Gamma J' / n
#
# with Gamma the normal-theory moment covariance (js_gamma = "nt", the
# default; evaluated at the model-implied moments unless
# js_vcov_gamma_modelbased = FALSE) or the distribution-free (ADF) moment
# covariance (js_gamma = "adf", requires raw data). Because the Jacobian
# runs through the complete map, the extra variability caused by
# estimating the reliability matrix (Burghgraeve et al. 2021, Theorem 2)
# -- and, for JSA, the aggregation weights -- is included, and the
# directed/undirected cross-covariances are available (e.g. for defined
# parameters).
#
# The Jacobian is computed analytically (js_jacobian = "analytic", the
# default; see lav_sem_js_deriv.R) -- a single chain-rule sweep, so the
# standard errors stay 'non-iterative'. js_jacobian = "numeric" requests
# the numerical (numDeriv) Jacobian of the estimation map instead; the
# analytic path also falls back to it for the few configurations it does
# not cover (general linear equality constraints among the
# variance/covariance parameters, non-PD or non-converged stage-2 weight
# matrices).
lav_sem_js_vcov <- function(lavmodel = NULL, lavsamplestats = NULL,
                            lavoptions = NULL, lavpartable = NULL,
                            lavimplied = NULL,
                            lavh1 = NULL, lavdata = NULL, eqs = NULL) {
  lavpta <- lav_pt_attributes(lavpartable)
  lavpartable <- lav_pt_set_cache(lavpartable, lavpta)
  aggregated <- identical(lavoptions$estimator, "JSA")

  # directed versus undirected (free) parameters
  undirected_idx <- which(lavpartable$free > 0L &
    !duplicated(lavpartable$free) &
    lavpartable$op == "~~")
  directed_idx <- which(lavpartable$free > 0L &
    !duplicated(lavpartable$free) &
    lavpartable$op %in% c("=~", "~", "~1"))
  free_directed_idx <- unique(lavpartable$free[directed_idx])
  free_undirected_idx <- unique(lavpartable$free[undirected_idx])

  if (is.null(eqs)) {
    eqs <- lav_model_find_iv(lavmodel = lavmodel, lavpartable = lavpartable)
  }

  # Jacobian of the complete estimation map over the stacked moments:
  # analytic (default) or numerical
  js_jacobian <- tolower(lavoptions$estimator.args[["js_jacobian"]])
  if (length(js_jacobian) == 0L) {
    js_jacobian <- "analytic"
  }
  jac <- NULL
  if (js_jacobian == "analytic") {
    jac <- try(
      lav_sem_js_jacobian(
        lavmodel = lavmodel, lavsamplestats = lavsamplestats,
        lavoptions = lavoptions, lavpartable = lavpartable,
        lavh1 = lavh1, eqs = eqs
      ),
      silent = TRUE
    )
    # NULL = configuration not covered (silent, intended); "[JS]"-prefixed
    # stops = data-dependent conditions the analytic path cannot handle
    # (silent as well); anything else is an unexpected failure -- fall
    # back to the numerical Jacobian, but say so
    if (inherits(jac, "try-error")) {
      msg <- conditionMessage(attr(jac, "condition"))
      if (!grepl("[JS]", msg, fixed = TRUE)) {
        lav_msg_warn(gettextf(
          "[JS] analytic Jacobian failed (%s); falling back to the
           numerical Jacobian.", msg))
      }
      jac <- NULL
    }
  }
  if (is.null(jac)) {
    vec0 <- lav_implied_to_vec(
      implied = lavh1$implied, lavmodel = lavmodel, drop_list = TRUE
    )
    # r = 2 Richardson extrapolation is accurate to ~1e-6 relative here
    # and halves the number of map evaluations (the map itself is smooth)
    jac <- numDeriv::jacobian(
      func = function(v) {
        as.numeric(lav_sem_js_estimate(
          vec = v, eqs = eqs,
          lavmodel = lavmodel, lavpartable = lavpartable,
          lavsamplestats = lavsamplestats, lavh1 = lavh1,
          lavoptions = lavoptions,
          free_directed_idx = free_directed_idx,
          free_undirected_idx = free_undirected_idx,
          aggregated = aggregated
        ))
      },
      x = vec0, method.args = list(r = 2L)
    )
  }

  # moment covariance: block-diagonal over the groups,
  # gamma_big = bdiag(Gamma_b / nobs_b)
  js_gamma <- tolower(lavoptions$estimator.args[["js_gamma"]])
  if (length(js_gamma) == 0L) {
    js_gamma <- "nt"
  }
  gamma_modelbased <-
    !isFALSE(lavoptions$estimator.args[["js_vcov_gamma_modelbased"]])
  two_stage <- any(lavoptions$missing == c("two.stage", "robust.two.stage"))
  if (js_gamma == "adf") {
    if (lavdata@data.type != "full" || two_stage) {
      lav_msg_warn(gettext(
        "[JS] js_gamma = \"adf\" requires complete raw data; using the
         default moment covariance instead."))
      js_gamma <- "nt"
    }
  }
  # vcov = sum over blocks of J_b Gamma_b J_b' / nobs_b; for the (default)
  # normal-theory Gamma the sandwich is computed directly from Sigma_b
  # (lav_mat_k_gammant_kt with K = J_b), without ever forming the
  # pstar x pstar Gamma matrix -- for larger models building that matrix
  # dominates the whole standard-error computation
  nblocks <- lavmodel@nblocks
  npar <- nrow(jac)
  vcov <- matrix(0, nrow = npar, ncol = npar)
  offset <- 0L
  for (b in seq_len(nblocks)) {
    nvar_b <- lavmodel@nvar[b]
    nd_b <- if (lavmodel@meanstructure) {
      nvar_b + nvar_b * (nvar_b + 1L) / 2L
    } else {
      nvar_b * (nvar_b + 1L) / 2L
    }
    jac_b <- jac[, offset + seq_len(nd_b), drop = FALSE]
    offset <- offset + nd_b
    gg <- NULL
    if (two_stage) {
      # two-stage missing data: the moments are the (saturated) EM
      # estimates; their asymptotic covariance is the inverse saturated
      # information (two.stage; Savalei & Bentler 2009) or its sandwich
      # (robust.two.stage; Savalei & Falk 2014)
      mu_b <- lavh1$implied$mean[[b]]
      sig_b <- lavh1$implied$cov[[b]]
      if (identical(tolower(lavoptions$missing), "robust.two.stage")) {
        gg <- lav_mvn_mi_h1_omega_sw(
          y = lavdata@X[[b]], mp = lavdata@Mp[[b]],
          yp = lavsamplestats@missing[[b]],
          mu = mu_b, sigma_1 = sig_b,
          x_idx = integer(0L), information = "observed"
        )
      } else {
        i1 <- lav_mvnorm_missing_information_observed_samplestats(
          yp = lavsamplestats@missing[[b]],
          mu = mu_b, sigma_1 = sig_b, x_idx = integer(0L)
        )
        gg <- lav_mat_sym_inverse(i1)
      }
    } else if (js_gamma == "adf") {
      gg <- lav_samp_gamma(
        m_y = lavdata@X[[b]],
        meanstructure = lavmodel@meanstructure
      )
    } else {
      cov_b <- NULL
      if (gamma_modelbased) {
        cov_b <- lavimplied$cov[[b]]
        # the model-implied covariance may be non-PD (eg a Heywood case)
        if (inherits(try(chol(cov_b), silent = TRUE), "try-error")) {
          cov_b <- NULL
        }
      }
      if (is.null(cov_b)) {
        cov_b <- lavh1$implied$cov[[b]]
      }
      vcov <- vcov + lav_mat_k_gammant_kt(
        m_k = jac_b, s = cov_b,
        meanstructure = lavmodel@meanstructure,
        x_idx = integer(0L)
      ) / lavsamplestats@nobs[[b]]
    }
    if (!is.null(gg)) {
      vcov <- vcov + (jac_b %*% gg %*% t(jac_b)) / lavsamplestats@nobs[[b]]
    }
  }
  vcov <- (vcov + t(vcov)) / 2

  # the rest of lavaan expects the vcov of a ceq.simple.only model in the
  # 'unco' space (one row/column per non-collapsed parameter); the vcov
  # above is built in the compact (nx.free) space, so expand it via the
  # ceq.simple.K mapping (as for the IV estimator)
  if (lavmodel@ceq.simple.only && nrow(lavmodel@ceq.simple.K) > 0L) {
    vcov <- lavmodel@ceq.simple.K %*% vcov %*% t(lavmodel@ceq.simple.K)
  }

  vcov
}


# model-class checks for the JS/JSA estimators; stop with a clear message
# for anything the (phase-1) implementation does not cover
lav_sem_js_check <- function(lavmodel = NULL, lavpartable = NULL,
                             lavpta = NULL, lavdata = NULL,
                             lavoptions = NULL, aggregated = FALSE) {
  label <- if (aggregated) "JSA" else "JS"

  stopifnot(lavdata@nlevels == 1L)
  if (lavmodel@categorical) {
    lav_msg_stop(gettextf(
      "estimator %s does not support categorical data (yet).", label))
  }
  if (lavoptions$std.lv) {
    lav_msg_stop(gettextf(
      "estimator %s requires marker/scaling indicators;
       std_lv = TRUE is not supported.", label))
  }
  if (lavmodel@conditional.x) {
    lav_msg_stop(gettextf(
      "estimator %s does not support conditional_x = TRUE (yet).", label))
  }
  if (lavmodel@correlation) {
    lav_msg_stop(gettextf(
      "estimator %s does not support correlation structures (yet).", label))
  }
  if (isTRUE(lavmodel@composites)) {
    lav_msg_stop(gettextf(
      "estimator %s does not support composites (yet).", label))
  }
  if (lavmodel@nefa > 0L) {
    lav_msg_stop(gettextf(
      "estimator %s does not support efa blocks (yet).", label))
  }
  if (any(!vapply(lavdata@weights, is.null, logical(1L)))) {
    lav_msg_stop(gettextf(
      "estimator %s does not support sampling weights (yet).", label))
  }
  if (lavmodel@group.w.free) {
    lav_msg_stop(gettextf(
      "estimator %s does not support group_w_free = TRUE (yet).", label))
  }

  # the structural part must be recursive: the conditional expectations are
  # only valid regressors if the disturbance of each equation is
  # uncorrelated with (functions of) its right-hand-side variables
  if (any(vapply(lavmodel@modprop$acyclic, isFALSE, logical(1L)))) {
    lav_msg_stop(gettextf(
      "estimator %s requires a recursive structural model
       (no feedback loops).", label))
  }

  pt_block <- if (!is.null(lavpartable$block)) {
    lavpartable$block
  } else {
    rep(1L, length(lavpartable$op))
  }
  for (b in seq_len(lavmodel@nblocks)) {
    lav_sem_js_check_block(
      lavpartable = lavpartable, lavpta = lavpta, b = b,
      pt_block = pt_block, label = label, aggregated = aggregated
    )
  }

  invisible(TRUE)
}


# per-block model-structure checks
lav_sem_js_check_block <- function(lavpartable = NULL, lavpta = NULL,
                                   b = 1L, pt_block = NULL, label = "JS",
                                   aggregated = FALSE) {
  in_b <- pt_block == b
  lv_names <- lavpta$vnames$lv.regular[[b]]

  # higher-order factors: a latent variable measured by latent variables
  if (any(lavpartable$op == "=~" & in_b &
          lavpartable$rhs %in% lv_names)) {
    lav_msg_stop(gettextf(
      "estimator %s does not support higher-order factors (yet).", label))
  }

  # active covariance rows (free, or fixed to a nonzero value)
  cov_idx <- which(lavpartable$op == "~~" & in_b &
    lavpartable$lhs != lavpartable$rhs &
    (lavpartable$free > 0L |
      (!is.na(lavpartable$ustart) & lavpartable$ustart != 0)))

  # no disturbance of a dependent variable may be correlated with one of its
  # regressors, or with any ancestor of its regressors (the conditional
  # expectations condition on proxies of these variables)
  reg_idx <- which(lavpartable$op == "~" & in_b)
  if (length(reg_idx) > 0L && length(cov_idx) > 0L) {
    # ancestor sets via the regression graph (edge: rhs -> lhs)
    parents <- split(
      lavpartable$rhs[reg_idx],
      factor(lavpartable$lhs[reg_idx],
             levels = unique(lavpartable$lhs[reg_idx]))
    )
    ancestors <- function(vars) {
      anc <- character(0L)
      todo <- vars
      while (length(todo) > 0L) {
        v <- todo[1L]
        todo <- todo[-1L]
        if (v %in% anc) {
          next
        }
        anc <- c(anc, v)
        todo <- c(todo, parents[[v]])
      }
      anc
    }
    for (e in unique(lavpartable$lhs[reg_idx])) {
      rhs_e <- lavpartable$rhs[reg_idx][lavpartable$lhs[reg_idx] == e]
      anc_e <- ancestors(rhs_e)
      bad <- which((lavpartable$lhs[cov_idx] == e &
                      lavpartable$rhs[cov_idx] %in% anc_e) |
                   (lavpartable$rhs[cov_idx] == e &
                      lavpartable$lhs[cov_idx] %in% anc_e))
      if (length(bad) > 0L) {
        lav_msg_stop(gettextf(
          "estimator %1$s is not consistent when the disturbance of %2$s is
           correlated with (an ancestor of) one of its regressors: %3$s.",
          label, e,
          lav_msg_view(unique(c(lavpartable$lhs[cov_idx][bad],
                                lavpartable$rhs[cov_idx][bad])), "none")))
      }
    }
  }

  # residual covariances among the observed variables are handled by
  # per-equation clean conditioning sets (see lav_sem_js_eq_plans) and by
  # masking the contaminated tetrads in the reliability estimates (see
  # lav_sem_js_theta); unidentifiable configurations (no clean proxy, no
  # clean tetrad) are caught there with informative messages

  invisible(TRUE)
}


# residual-covariance ('contamination') map for one block: a symmetric
# logical nvar x nvar matrix flagging pairs of residual-bearing observed
# variables (indicators or endogenous observed variables) with an active
# (free, or fixed to a nonzero value) residual covariance. Such a pair
# invalidates any conditioning set that combines one member with the
# other member's equation, and any Spearman tetrad through the pair.
lav_sem_js_contam <- function(lavpartable = NULL, pt_block = NULL, b = 1L,
                              ov_names = NULL, lavpta = NULL) {
  nvar <- length(ov_names)
  contam <- matrix(FALSE, nrow = nvar, ncol = nvar)
  in_b <- pt_block == b
  ind_names <- unique(lavpartable$rhs[lavpartable$op == "=~" & in_b])
  res_names <- unique(c(ind_names, lavpta$vnames$eqs.y[[b]]))
  cov_idx <- which(lavpartable$op == "~~" & in_b &
    lavpartable$lhs != lavpartable$rhs &
    (lavpartable$free > 0L |
      (!is.na(lavpartable$ustart) & lavpartable$ustart != 0)) &
    lavpartable$lhs %in% res_names & lavpartable$rhs %in% res_names)
  if (length(cov_idx) > 0L) {
    ii <- match(lavpartable$lhs[cov_idx], ov_names)
    jj <- match(lavpartable$rhs[cov_idx], ov_names)
    ok <- !is.na(ii) & !is.na(jj)
    contam[cbind(ii[ok], jj[ok])] <- TRUE
    contam[cbind(jj[ok], ii[ok])] <- TRUE
  }
  contam
}


# Spearman/communality residual variances with tetrad masking: like
# lav_cfa_theta_spearman (h2_i = mean over tetrads r_ia * r_ib / r_ab),
# but tetrads that run through a contaminated pair (an active residual
# covariance) are excluded from the averaging. When a variable has no
# clean within-factor tetrad left, the tetrads are formed with EXTERNAL
# variables z instead (Kano-1990 style: any observed model variable
# outside the factor whose residual is uncorrelated with both members;
# the formula r_ia * r_iz / r_az identifies the communality through the
# factor correlations). Returns NA for a variable without any clean
# tetrad at all. With no contaminated pairs within the factor block,
# this reproduces lav_cfa_theta_spearman exactly.
#
# s_full/contam cover ALL observed variables of the block; idx is the
# factor's indicator set; zpool the external candidates.
#
# deriv = TRUE also returns the Jacobian of the theta values with respect
# to vech(s_full) as the "dtheta" attribute (length(idx) x pstar), using
# the moment form of the tetrad: r_ia r_ib / r_ab = S_ia S_ib / (S_ii S_ab)
# (used by the analytic delta-method standard errors; see
# lav_sem_js_deriv.R)
lav_sem_js_theta_spearman_masked <- function(s_full = NULL, idx = NULL,
                                             contam = NULL,
                                             zpool = integer(0L),
                                             bounds = "wide",
                                             deriv = FALSE) {
  r <- cov2cor(s_full)
  p <- length(idx)
  out <- rep(as.numeric(NA), p)
  dout <- NULL
  cm <- NULL
  if (deriv) {
    nvar <- nrow(s_full)
    pstar <- nvar * (nvar + 1L) / 2L
    dout <- matrix(0, nrow = p, ncol = pstar)
    # vech coordinate of the (i, j) entry (lav_mat_vech order)
    cm <- matrix(0L, nrow = nvar, ncol = nvar)
    cm[lower.tri(cm, diag = TRUE)] <- seq_len(pstar)
    cm <- cm + t(cm) - diag(diag(cm))
  }
  for (k in seq_len(p)) {
    i <- idx[k]
    others <- setdiff(idx, i)
    # clean within-factor tetrads; tet collects the (a, b) pairs actually
    # used (for the derivative)
    vals <- numeric(0L)
    tet <- matrix(0L, nrow = 2L, ncol = 0L)
    if (length(others) >= 2L) {
      pairs <- utils::combn(others, 2L)
      for (m in seq_len(ncol(pairs))) {
        a <- pairs[1L, m]
        b <- pairs[2L, m]
        if (contam[i, a] || contam[i, b] || contam[a, b]) {
          next
        }
        vals <- c(vals, r[i, a] * r[i, b] / r[a, b])
        if (deriv) {
          tet <- cbind(tet, c(a, b))
        }
      }
    }
    # fall back to external tetrads when no clean within-factor tetrad
    if (length(vals) == 0L && length(zpool) > 0L) {
      for (a in others) {
        if (contam[i, a]) {
          next
        }
        for (z in zpool) {
          if (contam[i, z] || contam[a, z]) {
            next
          }
          vals <- c(vals, r[i, a] * r[i, z] / r[a, z])
          if (deriv) {
            tet <- cbind(tet, c(a, z))
          }
        }
      }
    }
    if (length(vals) == 0L) {
      next # no clean tetrad: NA
    }
    h2 <- mean(vals)
    clipped <- FALSE
    if (bounds == "standard") {
      clipped <- h2 < 0 || h2 > 1
      h2[h2 < 0] <- 0
      h2[h2 > 1] <- 1
    } else if (bounds == "wide") {
      clipped <- h2 < -0.05 || h2 > +1.20
      h2[h2 < -0.05] <- -0.05
      h2[h2 > +1.20] <- +1.20
    }
    out[k] <- (1 - h2) * s_full[i, i]
    if (deriv) {
      # theta_k = (1 - h2) * S_ii with h2 the mean over the tetrads
      # v = S_ia S_ib / (S_ii S_ab); when h2 was clipped, dh2 = 0
      if (!clipped) {
        dh2 <- numeric(ncol(dout))
        s_ii <- s_full[i, i]
        for (m in seq_len(ncol(tet))) {
          a <- tet[1L, m]
          b <- tet[2L, m]
          s_ia <- s_full[i, a]
          s_ib <- s_full[i, b]
          s_ab <- s_full[a, b]
          dh2[cm[i, a]] <- dh2[cm[i, a]] + s_ib / (s_ii * s_ab)
          dh2[cm[i, b]] <- dh2[cm[i, b]] + s_ia / (s_ii * s_ab)
          dh2[cm[a, b]] <- dh2[cm[a, b]] - s_ia * s_ib / (s_ii * s_ab^2)
          dh2[cm[i, i]] <- dh2[cm[i, i]] - s_ia * s_ib / (s_ii^2 * s_ab)
        }
        dh2 <- dh2 / ncol(tet)
        dout[k, ] <- -s_full[i, i] * dh2
      }
      dout[k, cm[i, i]] <- dout[k, cm[i, i]] + (1 - h2)
    }
  }
  if (deriv) {
    attr(out, "dtheta") <- dout
  }
  out
}


# residual (measurement-error) variances of the observed indicators, needed
# for the shrinkage (and, for JSA, the aggregation weights); by default the
# Spearman/communality estimator applied per factor (Burghgraeve et al.,
# 2021, Appendix B), with contaminated tetrads masked (see
# lav_sem_js_theta_spearman). Indicators without a clean tetrad get NA;
# whether that is a problem depends on whether they are needed as a
# conditioning proxy (checked in lav_sem_js_eq_plans).
lav_sem_js_theta <- function(s = NULL, ind_pat = NULL, ov_names = NULL,
                             lv_names = NULL, lavoptions = NULL,
                             aggregated = FALSE, contam = NULL,
                             theta_fixed = NULL, deriv = FALSE) {
  label <- if (aggregated) "JSA" else "JS"
  nvar <- ncol(s)
  pstar <- nvar * (nvar + 1L) / 2L
  ea <- lavoptions$estimator.args
  js_theta <- tolower(ea[["js_theta"]])
  if (length(js_theta) == 0L) {
    js_theta <- "spearman"
  }
  js_theta_bounds <- tolower(ea[["js_theta_bounds"]])
  if (length(js_theta_bounds) == 0L) {
    js_theta_bounds <- "wide"
  }

  # derivative of theta with respect to vech(s) (analytic standard errors)
  dtheta <- if (deriv) matrix(0, nrow = nvar, ncol = pstar) else NULL

  theta <- rep(as.numeric(NA), nvar)
  if (js_theta == "user") {
    values <- ea[["js_theta_values"]]
    ind_idx <- which(rowSums(ind_pat != 0) > 0L)
    missing_idx <- ind_idx[!ov_names[ind_idx] %in% names(values)]
    if (length(missing_idx) > 0L) {
      lav_msg_stop(gettextf(
        "js_theta_values should provide a residual variance for every
         indicator; missing: %s.",
        lav_msg_view(ov_names[missing_idx], "none")))
    }
    theta[ind_idx] <- as.numeric(values[ov_names[ind_idx]])
  } else {
    # spearman, per factor
    for (f in seq_len(ncol(ind_pat))) {
      idx <- which(ind_pat[, f] != 0)
      if (length(idx) >= 3L &&
          (is.null(contam) || !any(contam[idx, idx]))) {
        if (!deriv) {
          theta[idx] <- lav_cfa_theta_spearman(s[idx, idx, drop = FALSE],
            bounds = js_theta_bounds
          )
        } else {
          # same values as lav_cfa_theta_spearman (no tetrad is masked
          # when the factor block is clean), but with the derivative
          tf <- lav_sem_js_theta_spearman_masked(
            s_full = s, idx = idx,
            contam = matrix(FALSE, nrow = nvar, ncol = nvar),
            zpool = integer(0L),
            bounds = js_theta_bounds, deriv = TRUE
          )
          theta[idx] <- as.numeric(tf)
          dtheta[idx, ] <- attr(tf, "dtheta")
        }
      } else if (length(idx) >= 2L) {
        # contaminated within-factor tetrads are masked; when a variable
        # has none left -- including the 2-indicator case, which has no
        # within-factor tetrads at all -- external tetrads take over
        # (Kano-style, with the observed variables outside the factor in
        # the z role; this requires the factor to be correlated with
        # other model variables)
        contam_f <- if (is.null(contam)) {
          matrix(FALSE, nrow = nvar, ncol = nvar)
        } else {
          contam
        }
        tf <- lav_sem_js_theta_spearman_masked(
          s_full = s, idx = idx, contam = contam_f,
          zpool = setdiff(seq_len(nvar), idx),
          bounds = js_theta_bounds, deriv = deriv
        )
        theta[idx] <- as.numeric(tf)
        if (deriv) {
          dtheta[idx, ] <- attr(tf, "dtheta")
        }
      }
      # else (single indicator): only a model-fixed residual variance can
      # provide the reliability (below); otherwise theta stays NA and an
      # informative error is raised when the proxy is actually needed
    }

    # residual variances that are FIXED in the model provide the
    # reliability directly for factors where the tetrad estimator is not
    # available (fewer than 3 indicators); e.g. a single-indicator factor
    # with its residual variance fixed to zero, or a 2-indicator factor
    # with one known reliability
    small_fac <- which(colSums(ind_pat != 0) < 3L)
    if (length(small_fac) > 0L && !is.null(theta_fixed)) {
      for (f in small_fac) {
        idx <- which(ind_pat[, f] != 0)
        fix_idx <- idx[!is.na(theta_fixed[idx])]
        theta[fix_idx] <- theta_fixed[fix_idx]
        if (deriv && length(fix_idx) > 0L) {
          dtheta[fix_idx, ] <- 0 # model constants
        }
      }
    }
  }

  # non-indicator variables (observed covariates, endogenous observed
  # variables) are error-free proxies of themselves; indicators without a
  # clean tetrad keep NA
  nonind_idx <- which(rowSums(ind_pat != 0) == 0L)
  theta[nonind_idx] <- 0
  if (deriv && length(nonind_idx) > 0L) {
    dtheta[nonind_idx, ] <- 0
  }

  # keep the variances within [0, var(y)] (NA-safe)
  diag_s <- diag(s)
  too_small_idx <- which(theta < 0)
  theta[too_small_idx] <- 0
  too_large_idx <- which(theta > diag_s)
  if (length(too_large_idx) > 0L) {
    theta[too_large_idx] <- diag_s[too_large_idx]
  }
  if (deriv) {
    if (js_theta == "user") {
      # user-given constants; a value clipped below may still become
      # data-dependent, so zero the derivative BEFORE the clip handling
      dtheta[] <- 0
    }
    if (length(too_small_idx) > 0L) {
      dtheta[too_small_idx, ] <- 0
    }
    if (length(too_large_idx) > 0L) {
      # theta_i = S_ii: derivative is the (i, i) vech coordinate
      cm_diag <- lav_mat_diagh_idx(nvar)
      dtheta[too_large_idx, ] <- 0
      dtheta[cbind(too_large_idx, cm_diag[too_large_idx])] <- 1
    }
    attr(theta, "dtheta") <- dtheta
  }

  theta
}


# reliability-maximizing (alphamax; Bentler, 1968) aggregation weights: the
# maximizer of (w's w - w'diag(theta) w) / (w's w) is the generalized
# eigenvector of (diag(theta), s) with the smallest eigenvalue; normalized
# so that the weights sum to one
lav_sem_js_weights <- function(s = NULL, theta = NULL) {
  p <- ncol(s)
  if (p == 1L) {
    return(1)
  }
  # the "fallback" attribute flags the (locally constant) equal-weights
  # branches; the analytic Jacobian then uses a zero derivative
  equal_w <- rep(1 / p, p)
  attr(equal_w, "fallback") <- TRUE
  r_mat <- try(chol(s), silent = TRUE)
  if (inherits(r_mat, "try-error")) {
    return(equal_w)
  }
  r_inv <- backsolve(r_mat, diag(p))
  m_sym <- crossprod(r_inv, theta * r_inv) # t(Ri) %*% diag(theta) %*% Ri
  ev <- eigen((m_sym + t(m_sym)) / 2, symmetric = TRUE)
  w <- drop(r_inv %*% ev$vectors[, p]) # smallest eigenvalue
  sw <- sum(w)
  if (!is.finite(sw) || abs(sw) < .Machine$double.eps^0.5) {
    return(equal_w)
  }
  w / sw
}


# build the per-equation conditioning-set 'plans' for one block: for every
# latent regressor of every equation, the set of observed variables used as
# its conditioning proxy. The plans depend only on the MODEL STRUCTURE
# (which residual covariances are active), never on the data, so the
# estimation map remains a smooth function of the sample moments (needed
# for the delta-method standard errors).
#
# Default proxies: the scaling indicator (scaling mode) or the pure
# indicators of the factor excluding the dependent variable (aggregated
# mode). A proxy variable is invalid for an equation when it has an active
# residual covariance with the equation's dependent variable ('tainted'),
# when its own reliability cannot be estimated (theta NA), or when it has
# an active residual covariance with a proxy variable used elsewhere in
# the same conditioning set (which would contaminate the joint conditional
# expectation). Invalid variables are replaced by clean pure indicators of
# the same factor whose loadings are identified (fixed, or estimable from
# their own clean equation). An equation is 'deferred' when any of its
# proxies is not simply the (fixed-loading) scaling indicator: such
# equations need loading estimates from the clean equations and are solved
# in a second pass.
lav_sem_js_eq_plans <- function(eqs_b = NULL, aggregated = FALSE,
                                contam = NULL, ind_pat = NULL,
                                single_pat = NULL, markers = NULL,
                                ov_names = NULL, lv_names = NULL,
                                theta = NULL, lavpartable = NULL,
                                pt_block = NULL, b = 1L, label = "JS") {
  nvar <- length(ov_names)
  theta_na <- which(is.na(theta))
  any_contam <- any(contam)

  # loading availability: lambda(v, f) is identified before the deferred
  # pass when it is fixed, or when v's own (single-proxy) measurement
  # equation conditions on a clean scaling indicator
  mm_idx <- which(lavpartable$op == "=~" & pt_block == b)
  lam_free <- matrix(FALSE, nrow = nvar, ncol = length(lv_names))
  if (length(mm_idx) > 0L) {
    lam_free[cbind(
      match(lavpartable$rhs[mm_idx], ov_names),
      match(lavpartable$lhs[mm_idx], lv_names)
    )] <- lavpartable$free[mm_idx] > 0L
  }
  lam_available <- function(v, f) {
    if (!lam_free[v, f]) {
      return(TRUE) # fixed value
    }
    m_f <- match(markers[[lv_names[f]]], ov_names)
    !contam[m_f, v] && !v %in% theta_na
  }

  plans <- vector("list", length(eqs_b))
  deferred <- logical(length(eqs_b))
  for (j in seq_along(eqs_b)) {
    eq <- eqs_b[[j]]
    if (identical(eq$rhs_new, "1")) {
      next
    }
    y_idx <- match(eq$lhs_new, ov_names)
    is_lv <- eq$rhs %in% lv_names
    if (!any(is_lv)) {
      plans[[j]] <- vector("list", length(eq$rhs))
      next
    }
    tainted <- if (any_contam) which(contam[, y_idx]) else integer(0L)

    # candidate pool per latent regressor: clean pure indicators with an
    # estimable reliability and an identified loading
    fac <- match(eq$rhs, lv_names) # NA for observed regressors
    cand <- vector("list", length(eq$rhs))
    marker_idx <- rep(NA_integer_, length(eq$rhs))
    for (i in which(is_lv)) {
      f <- fac[i]
      marker_idx[i] <- match(markers[[eq$rhs[i]]], ov_names)
      pool <- which(ind_pat[, f] != 0 & single_pat)
      pool <- setdiff(pool, c(y_idx, tainted, theta_na))
      pool <- pool[vapply(pool, lam_available, logical(1L), f = f)]
      cand[[i]] <- pool
    }

    # initial sets
    sets <- vector("list", length(eq$rhs))
    for (i in which(is_lv)) {
      if (!aggregated) {
        sets[[i]] <- if (marker_idx[i] %in% cand[[i]]) {
          marker_idx[i]
        } else {
          cand[[i]]
        }
      } else {
        sets[[i]] <- cand[[i]]
      }
    }

    # fixpoint: drop (both members of) any contaminated pair within or
    # across the chosen sets; dropped variables never come back, so this
    # terminates
    if (any_contam) {
      dropped <- integer(0L)
      repeat {
        used <- unlist(sets[is_lv])
        pair_idx <- which(contam[used, used, drop = FALSE], arr.ind = TRUE)
        if (nrow(pair_idx) == 0L) {
          break
        }
        bad_vars <- unique(used[unique(as.vector(pair_idx))])
        dropped <- c(dropped, bad_vars)
        for (i in which(is_lv)) {
          sets[[i]] <- setdiff(sets[[i]], dropped)
          if (length(sets[[i]]) == 0L) {
            # refill from the candidate pool (minus everything dropped)
            sets[[i]] <- setdiff(cand[[i]], dropped)
          }
        }
        if (any(vapply(sets[is_lv], length, integer(1L)) == 0L)) {
          break
        }
      }
    }

    # identifiable?
    for (i in which(is_lv)) {
      if (length(sets[[i]]) == 0L) {
        lav_msg_stop(gettextf(
          "estimator %1$s cannot construct an admissible conditioning proxy
           for %2$s in the equation for %3$s: no indicator with an
           estimable reliability is available (too few indicators without
           a fixed residual variance, or too many residual covariances).
           Consider providing the residual variances directly via
           estimator_args = list(js_theta = \"user\",
           js_theta_values = ...).",
          label, eq$rhs[i], eq$lhs_new))
      }
    }

    plans[[j]] <- sets
    # deferred: any proxy that is not simply the fixed-loading scaling
    # indicator (the plain-marker solve needs no loading estimates)
    deferred[j] <- any(vapply(which(is_lv), function(i) {
      !(length(sets[[i]]) == 1L && sets[[i]] == marker_idx[i] &&
          !lam_free[marker_idx[i], fac[i]])
    }, logical(1L)))
  }

  list(plans = plans, deferred = deferred)
}


# stage 1: estimate all directed (loading/regression/intercept) parameters,
# equation by equation, by least squares on the estimated conditional
# expectations -- computed directly from the sample moments.
# if samplestats = TRUE, x should contain the stacked sample statistics
# (as produced by lav_implied_to_vec); this mode is used to compute the
# moment Jacobian for the standard errors
lav_sem_js_stage1_samp <- function(x = NULL, samplestats = FALSE,
                                   eqs = NULL, lavmodel = NULL,
                                   lavpartable = NULL,
                                   lavsamplestats = NULL, lavh1 = NULL,
                                   lavoptions = NULL,
                                   free_directed_idx = NULL,
                                   aggregated = FALSE,
                                   record = FALSE) {
  # sample moments
  if (samplestats) {
    implied <- lav_vec_to_implied(x, lavmodel = lavmodel)
  } else {
    implied <- lavh1$implied
  }
  x <- lav_model_get_parameters(lavmodel)

  lavpta <- lav_pt_attributes(lavpartable)
  pt_block <- if (!is.null(lavpartable$block)) {
    lavpartable$block
  } else {
    rep(1L, length(lavpartable$op))
  }

  block_trace <- if (record) {
    vector("list", lavmodel@nblocks)
  } else {
    NULL
  }
  for (b in seq_len(lavmodel@nblocks)) {
    s_mat <- implied$cov[[b]]
    m_vec <- if (lavmodel@meanstructure) implied$mean[[b]] else NULL
    n <- lavsamplestats@nobs[[b]]
    ov_names <- lavpta$vnames$ov[[b]]
    lv_names <- lavpta$vnames$lv.regular[[b]]
    markers <- lavpta$vnames$lv.marker[[b]] # named by lv
    nvar <- length(ov_names)
    nfac <- length(lv_names)

    # indicator pattern (nvar x nfac)
    ind_pat <- matrix(0, nrow = nvar, ncol = nfac)
    mm_idx <- which(lavpartable$op == "=~" & pt_block == b)
    if (length(mm_idx) > 0L && nfac > 0L) {
      ind_pat[cbind(
        match(lavpartable$rhs[mm_idx], ov_names),
        match(lavpartable$lhs[mm_idx], lv_names)
      )] <- 1
    }

    single_pat <- rowSums(ind_pat != 0) == 1L
    label <- if (aggregated) "JSA" else "JS"

    # residual-covariance (contamination) map + residual variances of the
    # proxies (per block; contaminated tetrads are masked)
    contam <- lav_sem_js_contam(
      lavpartable = lavpartable, pt_block = pt_block, b = b,
      ov_names = ov_names, lavpta = lavpta
    )
    # residual variances that are FIXED in the model (used for factors
    # with fewer than 3 indicators, where the tetrad estimator is not
    # available)
    theta_fixed <- rep(as.numeric(NA), nvar)
    var_idx <- which(lavpartable$op == "~~" & pt_block == b &
      lavpartable$lhs == lavpartable$rhs &
      lavpartable$free == 0L & !is.na(lavpartable$ustart) &
      lavpartable$lhs %in% ov_names)
    if (length(var_idx) > 0L) {
      theta_fixed[match(lavpartable$lhs[var_idx], ov_names)] <-
        lavpartable$ustart[var_idx]
    }
    theta <- lav_sem_js_theta(
      s = s_mat, ind_pat = ind_pat, ov_names = ov_names,
      lv_names = lv_names,
      lavoptions = lavoptions, aggregated = aggregated, contam = contam,
      theta_fixed = theta_fixed, deriv = record
    )

    # small-sample (Efron-Morris) shrinkage factor
    csmall <- (n - 3) / (n - 1)
    if (isFALSE(lavoptions$estimator.args[["js_small_sample"]])) {
      csmall <- 1
    }

    # conditioning-set plans for the scaling passes
    pl <- lav_sem_js_eq_plans(
      eqs_b = eqs[[b]], aggregated = FALSE, contam = contam,
      ind_pat = ind_pat, single_pat = single_pat, markers = markers,
      ov_names = ov_names, lv_names = lv_names, theta = theta,
      lavpartable = lavpartable, pt_block = pt_block, b = b, label = label
    )

    # pass 1a: scaling; the equations that condition on plain (fixed-
    # loading) scaling indicators -- this also provides the loadings needed
    # to scale the other proxies
    lambda_mat <- lav_sem_js_lambda_mat(
      x = x, lavpartable = lavpartable, mm_idx = mm_idx,
      ov_names = ov_names, lv_names = lv_names
    )
    out <- lav_sem_js_eqs_pass(
      eqs_b = eqs[[b]], plans = pl$plans, only = which(!pl$deferred),
      s_mat = s_mat, m_vec = m_vec, n = n,
      ov_names = ov_names, lv_names = lv_names, markers = markers,
      contam = contam, theta = theta, csmall = csmall,
      lambda_mat = lambda_mat, lavpartable = lavpartable,
      lavmodel = lavmodel, x = x,
      record = record, pass_id = "p1a"
    )
    x <- out$x
    eqs[[b]] <- out$eqs

    # pass 1b: the deferred equations (clean-proxy conditioning sets that
    # need the pass-1a loading estimates)
    if (any(pl$deferred)) {
      lambda_mat <- lav_sem_js_lambda_mat(
        x = x, lavpartable = lavpartable, mm_idx = mm_idx,
        ov_names = ov_names, lv_names = lv_names
      )
      out <- lav_sem_js_eqs_pass(
        eqs_b = eqs[[b]], plans = pl$plans, only = which(pl$deferred),
        s_mat = s_mat, m_vec = m_vec, n = n,
        ov_names = ov_names, lv_names = lv_names, markers = markers,
        contam = contam, theta = theta, csmall = csmall,
        lambda_mat = lambda_mat, lavpartable = lavpartable,
        lavmodel = lavmodel, x = x,
        record = record, pass_id = "p1b"
      )
      x <- out$x
      eqs[[b]] <- out$eqs
    }

    # pass 2 (JSA): aggregated proxies, scaled by the scaling-pass loadings
    if (aggregated) {
      pla <- lav_sem_js_eq_plans(
        eqs_b = eqs[[b]], aggregated = TRUE, contam = contam,
        ind_pat = ind_pat, single_pat = single_pat, markers = markers,
        ov_names = ov_names, lv_names = lv_names, theta = theta,
        lavpartable = lavpartable, pt_block = pt_block, b = b, label = label
      )
      lambda_mat <- lav_sem_js_lambda_mat(
        x = x, lavpartable = lavpartable, mm_idx = mm_idx,
        ov_names = ov_names, lv_names = lv_names
      )
      out <- lav_sem_js_eqs_pass(
        eqs_b = eqs[[b]], plans = pla$plans,
        only = seq_along(eqs[[b]]),
        s_mat = s_mat, m_vec = m_vec, n = n,
        ov_names = ov_names, lv_names = lv_names, markers = markers,
        contam = contam, theta = theta, csmall = csmall,
        lambda_mat = lambda_mat, lavpartable = lavpartable,
        lavmodel = lavmodel, x = x,
        record = record, pass_id = "jsa"
      )
      x <- out$x
      eqs[[b]] <- out$eqs
    }

    if (record) {
      # per-block structure for the analytic Jacobian: the theta
      # derivative and the pass composition (which equations were solved
      # in which pass)
      passes <- list(p1a = which(!pl$deferred))
      if (any(pl$deferred)) {
        passes$p1b <- which(pl$deferred)
      }
      if (aggregated) {
        passes$jsa <- seq_along(eqs[[b]])
      }
      block_trace[[b]] <- list(
        theta = as.numeric(theta),
        dtheta = attr(theta, "dtheta"),
        csmall = csmall, mm_idx = mm_idx,
        ov_names = ov_names, lv_names = lv_names,
        passes = passes
      )
    }
  } # blocks

  # pooled (system) solve for shared free parameters among the directed
  # coefficients, across all blocks (a no-op when nothing is shared); this
  # handles simple (cross-group) equality constraints and general linear
  # equality constraints among the directed coefficients
  x <- lav_sem_miiv_apply_directed_pool(
    eqs_b = do.call(c, eqs), x = x, lavmodel = lavmodel,
    lavpartable = lavpartable
  )

  theta1 <- x[free_directed_idx]
  attr(theta1, "eqs") <- eqs
  if (record) {
    attr(theta1, "js_trace") <- block_trace
  }
  theta1
}


# the current loading estimates as an nvar x nfac matrix (fixed values
# from the parameter table; free values from the parameter vector x)
lav_sem_js_lambda_mat <- function(x = NULL, lavpartable = NULL,
                                  mm_idx = NULL, ov_names = NULL,
                                  lv_names = NULL) {
  lambda_mat <- matrix(0, nrow = length(ov_names), ncol = length(lv_names))
  if (length(mm_idx) > 0L && length(lv_names) > 0L) {
    lambda_val <- lavpartable$ustart[mm_idx]
    lambda_val[is.na(lambda_val)] <- 0
    free_mm <- which(lavpartable$free[mm_idx] > 0L)
    lambda_val[free_mm] <- x[lavpartable$free[mm_idx][free_mm]]
    lambda_mat[cbind(
      match(lavpartable$rhs[mm_idx], ov_names),
      match(lavpartable$lhs[mm_idx], lv_names)
    )] <- lambda_val
  }
  lambda_mat
}


# one pass over (a subset of) the equations of a single block: for each
# equation, build the conditioning set Z from its plan (a weighted
# aggregate over the planned proxy variables per latent regressor; the
# observed regressors themselves), and solve the least-squares regression
# of the dependent variable on the estimated conditional expectations --
# all in moment form:
#
#   design  d = G S_zz^{-1} Z   with row l of G = Cov(eta_l, Z)
#   amat = Var(d)  = G S_zz^{-1} G'
#   bvec = Cov(d, y) = G S_zz^{-1} Cov(Z, y)
#   slopes = solve(amat, bvec)
#
# for an observed regressor, the corresponding row of G equals the row of
# S_zz, so its 'conditional expectation' is the variable itself
lav_sem_js_eqs_pass <- function(eqs_b = NULL, plans = NULL, only = NULL,
                                s_mat = NULL, m_vec = NULL, n = NULL,
                                ov_names = NULL, lv_names = NULL,
                                markers = NULL, contam = NULL,
                                theta = NULL, csmall = 1,
                                lambda_mat = NULL, lavpartable = NULL,
                                lavmodel = NULL, x = NULL,
                                record = FALSE, pass_id = "p1a") {
  meanstructure <- lavmodel@meanstructure
  nvar <- length(ov_names)
  if (is.null(only)) {
    only <- seq_along(eqs_b)
  }

  for (j in only) {
    eq <- eqs_b[[j]]
    y_idx <- match(eq$lhs_new, ov_names)

    # intercept-only equation
    if (identical(eq$rhs_new, "1")) {
      if (meanstructure && length(eq$ptint) == 1L) {
        free_int_idx <- lavpartable$free[eq$ptint]
        if (free_int_idx > 0L) {
          x[free_int_idx] <- m_vec[y_idx]
        }
      }
      eqs_b[[j]]$coef <- if (meanstructure) m_vec[y_idx] else numeric(0L)
      eqs_b[[j]]$nobs <- n
      if (record) {
        eqs_b[[j]]$js_trace[[pass_id]] <- list(int_only = TRUE,
                                               y_idx = y_idx)
      }
      next
    }

    nrhs <- length(eq$rhs)
    is_lv <- eq$rhs %in% lv_names

    # conditioning set: one row of u_mat (over the observed variables) per
    # regressor, plus its scale (kappa) and error variance (errvar); the
    # proxy variable sets come from the (structural) plan
    u_mat <- matrix(0, nrow = nrhs, ncol = nvar)
    kappa <- rep(1, nrhs)
    errvar <- rep(0, nrhs)
    sets_used <- vector("list", nrhs)
    w_used <- vector("list", nrhs)
    lam_used <- vector("list", nrhs)
    w_fb <- logical(nrhs)
    for (i in seq_len(nrhs)) {
      if (!is_lv[i]) {
        u_mat[i, match(eq$rhs_new[i], ov_names)] <- 1
        next
      }
      f <- match(eq$rhs[i], lv_names)
      marker_idx <- match(markers[[eq$rhs[i]]], ov_names)
      set <- plans[[j]][[i]]
      if (length(set) == 1L) {
        w <- 1
      } else {
        w <- lav_sem_js_weights(
          s = s_mat[set, set, drop = FALSE],
          theta = theta[set]
        )
        w_fb[i] <- isTRUE(attr(w, "fallback"))
        w <- as.numeric(w)
      }
      kap <- sum(w * lambda_mat[set, f])
      if (!is.finite(kap) || abs(kap) < .Machine$double.eps^0.5) {
        # degenerate proxy scale; fall back to the scaling indicator when
        # it is admissible for this equation
        if (!contam[marker_idx, y_idx] && !is.na(theta[marker_idx]) &&
            !identical(set, marker_idx)) {
          set <- marker_idx
          w <- 1
          w_fb[i] <- FALSE
          kap <- lambda_mat[marker_idx, f]
        }
        if (!is.finite(kap) || abs(kap) < .Machine$double.eps^0.5) {
          lav_msg_stop(gettextf(
            "the conditioning proxy for %1$s in the equation for %2$s has a
             degenerate scale (aggregate loading near zero).",
            eq$rhs[i], eq$lhs_new))
        }
      }
      u_mat[i, set] <- w
      kappa[i] <- kap
      errvar[i] <- sum(w * w * theta[set])
      sets_used[[i]] <- set
      w_used[[i]] <- w
      lam_used[[i]] <- lambda_mat[set, f]
    }

    # moments of the conditioning variables
    s_zz <- u_mat %*% s_mat %*% t(u_mat)
    s_zy <- drop(u_mat %*% s_mat[, y_idx])

    # G: covariance of the regressors (latent or observed) with Z
    g_mat <- s_zz
    for (i in which(is_lv)) {
      g_mat[i, i] <- g_mat[i, i] - csmall * errvar[i]
    }
    if (any(is_lv)) {
      g_mat[is_lv, ] <- g_mat[is_lv, , drop = FALSE] / kappa[is_lv]
    }

    # normal equations of the regression on the conditional expectations
    s_zz_inv <- try(solve((s_zz + t(s_zz)) / 2), silent = TRUE)
    if (inherits(s_zz_inv, "try-error")) {
      lav_msg_stop(gettextf(
        "the conditioning variables of the equation for %s are collinear.",
        eq$lhs_new))
    }
    gsi <- g_mat %*% s_zz_inv
    amat <- gsi %*% t(g_mat)
    amat <- (amat + t(amat)) / 2
    bvec <- drop(gsi %*% s_zy)

    # means of the design (for the intercepts)
    if (meanstructure) {
      z_mean <- drop(u_mat %*% m_vec)
      x_bar <- z_mean
      x_bar[is_lv] <- z_mean[is_lv] / kappa[is_lv]
      y_bar <- m_vec[y_idx]
    } else {
      x_bar <- numeric(nrhs)
      y_bar <- 0
    }

    # free/fixed partition and solve
    eq_free_idx <- lavpartable$free[eq$pt]
    b_full <- lavpartable$ustart[eq$pt]
    b_full[is.na(b_full)] <- 0
    fix_idx <- which(eq_free_idx == 0L)
    if (length(fix_idx) > 0L) {
      amat_free <- amat[-fix_idx, -fix_idx, drop = FALSE]
      bvec_free <- drop(bvec[-fix_idx] -
        amat[-fix_idx, fix_idx, drop = FALSE] %*% b_full[fix_idx])
      gcol_free <- eq_free_idx[-fix_idx]
    } else {
      amat_free <- amat
      bvec_free <- bvec
      gcol_free <- eq_free_idx
    }
    if (length(gcol_free) > 0L) {
      slopes_free <- drop(solve(amat_free, bvec_free))
      x[gcol_free] <- slopes_free
      b_full[eq_free_idx > 0L] <- slopes_free
      # slope system for the pooled solve (simple equality constraints)
      eqs_b[[j]]$slope_block <- list(
        amat = (n - 1) * amat_free, bvec = (n - 1) * bvec_free,
        gcol = gcol_free, x_idx = integer(0L),
        x_bar = x_bar, y_bar = y_bar
      )
    }

    # intercept
    beta0 <- y_bar - sum(b_full * x_bar)
    if (meanstructure && length(eq$ptint) == 1L) {
      free_int_idx <- lavpartable$free[eq$ptint]
      if (free_int_idx > 0L) {
        x[free_int_idx] <- beta0
      }
    }

    eqs_b[[j]]$coef <- c(if (meanstructure) beta0 else NULL, b_full)
    eqs_b[[j]]$nobs <- n

    # record the resolved structure of this pass (proxy sets, weights,
    # branch decisions) for the analytic delta-method Jacobian (see
    # lav_sem_js_deriv.R); the derivative pass replays exactly these
    # decisions instead of re-deciding them
    if (record) {
      eqs_b[[j]]$js_trace[[pass_id]] <- list(
        int_only = FALSE, y_idx = y_idx, is_lv = is_lv,
        fac = match(eq$rhs, lv_names),
        sets = sets_used, w = w_used, lam = lam_used, w_fb = w_fb,
        u_mat = u_mat, kappa = kappa, errvar = errvar,
        s_zz = s_zz, g_mat = g_mat, amat = amat, bvec = bvec,
        fix_idx = fix_idx, gcol_free = gcol_free,
        b_full = b_full, x_bar = x_bar, y_bar = y_bar
      )
    }
  }

  list(x = x, eqs = eqs_b)
}

Try the lavaan package in your browser

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

lavaan documentation built on Oct. 8, 2026, 5:06 p.m.