R/nmf.ffb.R

Defines functions .ffb.attach.ident print.nmfkc.DOT plot.nmfkc.DOT nmfkc.DOT nmf.ffb.DOT nmf.ffb.split nmf.ffb.diagnostics nmf.ffb.cv nmf.ffb.inference .nmf.ffb.mu nmf.ffb

Documented in nmf.ffb nmf.ffb.cv nmf.ffb.diagnostics nmf.ffb.DOT nmf.ffb.inference nmf.ffb.split nmfkc.DOT plot.nmfkc.DOT print.nmfkc.DOT

# nmf.ffb.R — NMF-FFB (formerly NMF-SEM) canonical engines + generic DOT
# Canonical:  nmf.ffb, nmf.ffb.inference, nmf.ffb.cv, nmf.ffb.split, nmf.ffb.DOT
#             (the deprecated nmf.sem* aliases were removed in 0.9.8).
# Also hosts: nmfkc.DOT / plot.nmfkc.DOT (shared DOT utilities).

#------------------------------------------------------------------------------
#------------------------------------------------------------------------------
#------------------------------------------------------------------------------
#------------------------------------------------------------------------------
#------------------------------------------------------------------------------
#------------------------------------------------------------------------------
#' @title NMF-FFB: non-negative matrix factorization with latent feedback
#'
#' @description
#' Fits the NMF-FFB model
#' \deqn{
#'   Y_1 = X B + E, \qquad B = \Theta_1 Y_1 + \Theta_2 Y_2 + U,
#' }
#' in which a non-negative basis \eqn{X} (\eqn{P_1 \times Q}) generates the
#' endogenous block \eqn{Y_1} from latent scores \eqn{B} that are driven by
#' the exogenous block \eqn{Y_2} (feed-forward, \eqn{\Theta_2}) and fed back
#' by \eqn{Y_1} itself (latent feedback, \eqn{\Theta_1}).  The function
#' returns the estimated basis, the structural coefficient matrices and the
#' implied equilibrium (input-output) mapping.
#'
#' At equilibrium, the model can be written as
#' \deqn{
#'   Y_1 \approx (I - X \Theta_1)^{-1} X \Theta_2 Y_2
#'   \equiv M_{\mathrm{model}} Y_2,
#' }
#' where \eqn{M_{\mathrm{model}} = (I - X \Theta_1)^{-1} X \Theta_2} is a
#' Leontief-type cumulative-effect operator in latent space.
#'
#' Internally, the latent feedback and exogenous loading matrices are stored as
#' \code{C1} and \code{C2}, corresponding to \eqn{\Theta_1} and \eqn{\Theta_2},
#' respectively.
#'
#' Two estimators are available through \code{method}:
#' \describe{
#'   \item{\code{"fiml"} (default)}{A two-stage likelihood-based estimator.
#'     \strong{Stage 1} estimates the basis with the feed-forward fit
#'     \code{\link{nmfkc}(Y1, A = Y2)} (\code{X.init}, \code{X.L2.ortho},
#'     \code{epsilon}, \code{maxit}, \code{seed} are forwarded), unless a
#'     basis is supplied through \code{X}.  Columns are normalized to unit
#'     sum.  \strong{Stage 2} holds \eqn{\hat X} fixed and fits the Gaussian
#'     working model \eqn{U \sim N(0, \Phi)}, \eqn{E \sim N(0,
#'     \mathrm{diag}(\psi))} by full-information maximum likelihood
#'     (L-BFGS-B with an analytic gradient) under the non-negativity of
#'     \eqn{\Theta_1, \Theta_2} and an exclusion restriction on
#'     \eqn{\Theta_1}.  It fits (a) the feed-forward null \eqn{\Theta_1 = 0}
#'     (a non-negative MIMIC factor model with correlated factors), (b) the
#'     unpenalized feedback model on the admitted entries, and (c) an L1
#'     path over \code{C1.L1.path}.  The penalized problem is non-convex, so
#'     every point of the path is fitted from several starting points
#'     (\code{starts}); every distinct support proposed by any (penalty,
#'     start) pair is re-estimated without penalty (itself from three
#'     starts, keeping the best log-likelihood) and BIC is minimized over
#'     all distinct candidates together with the null and the unpenalized
#'     model.  The support with the smallest BIC is the \emph{selected}
#'     model, reported in \code{C1}, \code{C2}, \code{Phi}, \code{psi}.
#'     \code{C1.L1} and \code{C2.L1} are not used by this estimator.  The likelihood-ratio statistics against the null are
#'     returned without p-values: \eqn{\Theta_1 \ge 0} puts the null on the
#'     boundary of the parameter space and the BIC refit is a post-selection
#'     statistic, so a chi-square reference is not valid.  Their calibration
#'     by parametric bootstrap is done by \code{\link{nmf.ffb.inference}}.}
#'   \item{\code{"mu"}}{The legacy estimator: joint multiplicative updates of
#'     \eqn{X, \Theta_1, \Theta_2} for the structural-form squared error
#'     \eqn{\lVert Y_1 - X(\Theta_1 Y_1 + \Theta_2 Y_2)\rVert_F^2} with an
#'     orthogonality penalty on \eqn{X} and L1 penalties \code{C1.L1},
#'     \code{C2.L1}.  Kept, bit-identical, so that analyses published with it
#'     reproduce.  Because the structural and reduced forms have the same
#'     fit once \eqn{X} is free, this estimator cannot separate
#'     \eqn{\Theta_1} from \eqn{\Theta_2}; prefer \code{"fiml"}.}
#' }
#'
#' @section Exclusion restriction:
#' Feedback is identified only through exclusion restrictions: an outcome
#' may not feed back into a factor on which it has more than a negligible
#' loading, because such an entry is nearly equivalent to a change of the
#' outcome's loading and uniqueness.  With
#' \code{C1.restriction = "union"} (default) entry \eqn{(q, i)} of \eqn{\Theta_1} is
#' excluded if \eqn{q = \arg\max_{q'} X_{i q'}} (the dominant factor) or
#' \eqn{X_{iq} \ge} \code{C1.restriction.threshold}.  Both halves are needed:
#' blocking the dominant factor alone would leave an outcome with a
#' substantial second loading free to feed that factor, and blocking only the
#' factors above the threshold would leave an outcome whose largest loading is
#' below the threshold free to feed its own.
#' \code{"none"} frees every entry (not recommended: the model is then
#' identified only through the non-negativity and the covariance structure).
#' A user-supplied \eqn{Q \times P_1} 0/1 matrix is used as given.
#'
#' The restriction is derived from the estimated basis and therefore from the same
#' \eqn{Y_1} that is subsequently tested; see the \emph{Calibration} section
#' of \code{\link{nmf.ffb.inference}} for what this implies.
#'
#' @section Lifecycle:
#' \code{method = "fiml"} became the default in version 0.9.8, as did
#' \code{C1.restriction = "union"} (earlier fiml fits blocked the dominant
#' factor only), and
#' \code{\link{nmf.ffb.inference}} gained the \code{calibration} argument.
#' \code{method = "mu"} is the legacy estimator, kept for the reproducibility
#' of published analyses; it will be deprecated in a later release.
#'
#' @param Y1 A non-negative numeric matrix of endogenous variables with
#'   \strong{rows = variables (P1), columns = samples (N)}.
#' @param Y2 A non-negative numeric matrix of exogenous variables with
#'   \strong{rows = variables (P2), columns = samples (N)}.
#'   Must satisfy \code{ncol(Y1) == ncol(Y2)}.
#' @param rank Integer; number of latent factors \eqn{Q}. If \code{NULL},
#'   \eqn{Q} is taken from a hidden argument in \code{...} or defaults to
#'   \code{nrow(Y2)}.
#' @param X.init Initialization strategy for the basis matrix
#'   \code{X} (\eqn{P_1 \times Q}).  One of:
#'   \itemize{
#'     \item \code{"nndsvd"} (default): Non-negative Double SVD with
#'       additive randomness (NNDSVDar; Boutsidis & Gallopoulos 2008),
#'       computed internally via \code{.nndsvdar(Y1, Q)}.  Requires
#'       \eqn{Q \le \min(P_1, N)} (over-rank case falls back to
#'       \code{"runif"}).  Uses a full SVD of \eqn{Y_1}, so for very
#'       large \eqn{Y_1} consider switching to \code{"kmeans"} to
#'       avoid SVD memory / compute cost.
#'     \item \code{"kmeans"}: k-means on the columns of \eqn{Y_1}
#'       (samples clustered into \eqn{Q} groups); the transposed
#'       cluster centers become \eqn{X}.  Scales well for large
#'       \eqn{Y_1}; this is the default of \code{\link{nmfkc}}.
#'     \item \code{"kmeansar"}: \code{"kmeans"} followed by filling
#'       zero entries of \eqn{X} with \eqn{\mathrm{Uniform}(0,
#'       \bar Y_1 / 100)} (NNDSVDar-style additive randomness to
#'       escape trivial stationary points).
#'     \item \code{"runif"}: Uniform random entries in \eqn{[0, 1]}.
#'     \item \code{"spa"}: the anchor rows the successive projection
#'       algorithm finds in \eqn{Y_1} (see \code{\link{nmfkc}}); a starting
#'       value only.  \code{method = "fiml"} only.
#'     \item A numeric \eqn{P_1 \times Q} matrix supplied by the user;
#'       negative entries are projected to 0.
#'     \item \code{NULL}: backward-compatible alias for \code{"nndsvd"}.
#'   }
#'   To impose anchor rows on the stage-1 basis instead, pass
#'   \code{X.anchor} (\code{"spa"} or one row of \eqn{Y_1} per basis) through
#'   \code{...}, as in \code{\link{nmfkc}}: stage 2 holds the basis fixed, so
#'   the zeros stay.  It needs \code{rank <= nrow(Y2)}, is not available with
#'   \code{method = "mu"} or a supplied \code{X}, and the rows used are
#'   returned as \code{X.anchor}.
#'   In all cases the result is column-normalized to \code{colSums(X) = 1}
#'   before iteration.  The menu mirrors \code{\link{nmfkc}}'s
#'   \code{X.init} option for consistency across the package.
#' @param X.L2.ortho L2 orthogonality penalty for \code{X}. This controls
#'   the penalty term \eqn{\lambda_X \lVert X^\top X - \mathrm{diag}(X^\top X)
#'   \rVert_F^2}. Default: \code{100}.
#' @param C1.L1 L1 sparsity penalty for \code{C1} (i.e., \eqn{\Theta_1}).
#'   Default: \code{1.0}.  \strong{Scale note}: this function adds
#'   \code{C1.L1} to the multiplicative denominator, whereas
#'   \code{\link{nmfkc}}, \code{\link{nmf.rrr}} and \code{\link{nmfkc.net}} add
#'   \code{C.L1 / 2}.  The same nominal value is therefore twice as strong
#'   here.  The difference is retained so that published \code{nmf.ffb} fits
#'   reproduce; halve the value to match the other models.
#' @param C2.L1 L1 sparsity penalty for \code{C2} (i.e., \eqn{\Theta_2}).
#'   Default: \code{0.1}.  Same scale note as \code{C1.L1}.
#' @param epsilon Relative convergence threshold for the objective function.
#'   Iterations stop when the relative change in \strong{reconstruction loss}
#'   falls below this value. Default: \code{1e-6}.
#'   \strong{Note}: the test is on the unpenalized loss (\code{objfunc}), not
#'   on the penalized objective the updates actually minimize
#'   (\code{objfunc.penalized}).  Every other optimizer in the package tests the
#'   penalized value, so with a large \code{X.L2.ortho} or \code{C*.L1} this
#'   function can stop while the quantity being optimized is still moving.
#'   Both traces are returned; compare them if the penalties are strong.
#' @param maxit Maximum number of iterations for the multiplicative updates.
#'   Default: \code{5000} (matches \code{\link{nmfkc}} and other MU
#'   functions in the package).
#' @param seed Random seed used to initialize \code{X}, \code{C1}, and \code{C2}.
#'   Default: \code{123}.  For \code{method = "fiml"} the seed only reaches
#'   the stage-1 \code{\link{nmfkc}} fit; the FIML stage is deterministic.
#' @param method \code{"fiml"} (default) or \code{"mu"}; see Description.
#' @param X Optional basis for \code{method = "fiml"}: a \eqn{P_1 \times Q}
#'   non-negative matrix, or an \code{\link{nmfkc}} / \code{nmf.ffb} object
#'   whose \code{$X} is used.  When supplied, stage 1 is skipped and
#'   \code{rank} is taken from \code{ncol(X)}.
#' @param C1.restriction Exclusion restriction on \eqn{\Theta_1} for
#'   \code{method = "fiml"}: \code{"union"} (default), \code{"none"}, or a
#'   \eqn{Q \times P_1} 0/1 matrix
#'   (1 = free).  See the section \emph{Exclusion restriction}.
#' @param C1.restriction.threshold Loading threshold for
#'   \code{C1.restriction = "union"}.  Default \code{0.05}.
#' @param Phi.restriction Covariance of the latent disturbance \eqn{U} for
#'   \code{method = "fiml"}: \code{"full"} (default; positive definite via
#'   Cholesky) or \code{"diag"}.
#' @param C1.L1.path Numeric vector of L1 penalties on \eqn{\Theta_1} defining
#'   the path (\code{method = "fiml"}).  Default \code{NULL}, meaning
#'   \code{N * c(0.002, 0.005, 0.01, 0.02, 0.05, 0.1, 0.2, 0.5)}.
#' @param select \code{"BIC"} (default): the support with the smallest BIC
#'   among all candidates proposed along the path is the selected model;
#'   \code{"none"}: the unpenalized feedback fit is returned as the selected
#'   model and the path is skipped.
#' @param ... Additional hidden arguments.  For \code{method = "fiml"}:
#'   \code{fiml.maxit} (L-BFGS-B iteration cap, default \code{3000}),
#'   \code{factr} (\code{optim} tolerance, default \code{1e3}), and
#'   \code{Q} (alias of \code{rank}).  For \code{method = "mu"} the
#'   following control the optional feedforward baseline (used both as an
#'   \eqn{X} warm-start and as the reference for \code{SC.map}, the
#'   input-output structural fidelity defined in Satoh (2025) §4.SC.map):
#'   \describe{
#'     \item{\code{nmfkc.baseline}}{Controls whether a feedforward
#'       \code{\link{nmfkc}}(Y1, A = Y2) fit is used as baseline.
#'       Possible values:
#'       \itemize{
#'         \item Default (not given) — \code{nmf.ffb} runs
#'           \code{\link{nmfkc}} \strong{internally} when \code{X.init}
#'           is a string method (\code{"nndsvd"}, \code{"kmeans"},
#'           \dots) or \code{NULL}, forwarding \code{X.init},
#'           \code{X.L2.ortho}, \code{epsilon}, \code{maxit},
#'           \code{seed}.  The fitted \eqn{X} of the baseline is then
#'           used as warm-start for the nmf.ffb MU iterations, and
#'           \code{SC.map} is computed.  This means
#'           \code{nmf.ffb(Y1, Y2, rank = Q)} runs end-to-end without
#'           a prior \code{nmfkc} call.
#'         \item \code{TRUE} — same as above, but force the internal
#'           \code{\link{nmfkc}} call even when \code{X.init} is a
#'           user-supplied matrix (the matrix is overridden).
#'         \item \code{FALSE} — opt out; no internal call,
#'           \code{SC.map = NA} (pre-v0.6.8 behavior).
#'         \item An \code{\link{nmfkc}} result (list with \code{$X}
#'           and \code{$C}) — use as the baseline directly (no
#'           internal call); also adopted as \code{X.init} when the
#'           latter is a string / NULL.
#'       }}
#'     \item{\code{M.simple}}{Optional \eqn{P_1 \times P_2} pre-computed
#'       baseline mapping.  Takes precedence over \code{nmfkc.baseline}
#'       for the SC.map calculation but does not affect warm-start.}
#'     \item{\code{Q}}{Backward-compat alias for \code{rank}.}
#'   }
#'
#' @return An object of class \code{c("nmf.ffb", "nmf")}, a list
#'   with components:
#'   \item{X}{Estimated basis matrix (\eqn{P_1 \times Q}).}
#'   \item{C1}{Estimated latent feedback matrix (\eqn{\Theta_1}, \eqn{Q \times P_1});
#'     for \code{"fiml"} the BIC-selected estimate.}
#'   \item{C2}{Estimated exogenous loading matrix (\eqn{\Theta_2}, \eqn{Q \times P_2}).}
#'   \item{XC1}{Feedback matrix \eqn{X \Theta_1}.}
#'   \item{XC2}{Direct-effect matrix \eqn{X \Theta_2}.}
#'   \item{XC1.radius}{Spectral radius \eqn{\rho(X \Theta_1)}.}
#'   \item{XC1.norm1}{Induced 1-norm \eqn{\lVert X \Theta_1 \rVert_{1,\mathrm{op}}}.}
#'   \item{Leontief.inv}{Leontief-type inverse \eqn{(I - X \Theta_1)^{-1}.}}
#'   \item{M.model}{Equilibrium mapping
#'     \eqn{M_{\mathrm{model}} = (I - X \Theta_1)^{-1} X \Theta_2}.}
#'   \item{amplification}{Latent amplification factor
#'     \eqn{\lVert M_{\mathrm{model}} \rVert_{1,\mathrm{op}} /
#'          \bigl\lVert X \Theta_2 \bigr\rVert_{1,\mathrm{op}}}; meaningful
#'     only when \code{XC1.radius > 0} (it equals 1 when no feedback is
#'     selected).}
#'   \item{amplification.bound}{Geometric-series upper bound
#'     \eqn{1 / (1 - \lVert X \Theta_1 \rVert_{1,\mathrm{op}})} if
#'     \eqn{\lVert X \Theta_1 \rVert_{1,\mathrm{op}} < 1}, otherwise \code{Inf}.}
#'   \item{rank}{Effective latent dimension used in the fit.}
#'   \item{SC.cov}{Correlation between sample and model-implied covariance
#'     (flattened) of \eqn{Y_1}.  See \emph{second-moment fidelity} in
#'     Satoh (2025).}
#'   \item{SC.map}{Correlation between the equilibrium operator
#'     \eqn{M_{\mathrm{model}}} and a feedforward baseline mapping
#'     \eqn{M_{\mathrm{simple}} = X_0 \Theta_0}, computed only when the
#'     baseline is supplied via \code{M.simple} or \code{nmfkc.baseline}
#'     in \code{...}; otherwise \code{NA}.  See \emph{input-output
#'     structural fidelity} in Satoh (2025).}
#'   \item{mae}{Mean absolute error between \eqn{Y_1} and its equilibrium
#'     prediction \eqn{\hat Y_1 = M_{\mathrm{model}} Y_2}.}
#'   \item{objfunc}{Vector of reconstruction losses per iteration
#'     (\code{"mu"}); \code{NULL} for \code{"fiml"}.}
#'   \item{objfunc.penalized}{Vector of penalized objective values per iteration
#'     (\code{"mu"}); \code{NULL} for \code{"fiml"}.}
#'   \item{iter, maxit, epsilon, converged}{Convergence bookkeeping.  For
#'     \code{"fiml"}: the number of objective evaluations of the selected
#'     fit, the L-BFGS-B cap (\code{fiml.maxit}), the stage-1 tolerance,
#'     and \code{optim()$convergence == 0}.}
#'   \item{method}{\code{"fiml"} or \code{"mu"}.}
#'
#'   The following are present for \code{method = "fiml"} only
#'   (\code{SC.cov} and \code{SC.map} are then \code{NULL}):
#'   \item{Phi, psi, loglik, npar}{Latent-disturbance covariance
#'     (\eqn{Q \times Q}), unique variances (length \eqn{P_1}),
#'     log-likelihood and parameter count of the selected model.}
#'   \item{null}{The feed-forward null: list \code{C2, Phi, psi, loglik,
#'     npar, M.model}.}
#'   \item{full}{The unpenalized feedback fit: list \code{C1, C2, Phi, psi,
#'     loglik, npar, XC1.radius}.}
#'   \item{path}{Data frame with one row per (\code{C1.L1.path}, start) of
#'     the L1 path (\code{C1.L1.path = 0} is the unpenalized fit re-estimated
#'     on its non-zero entries, \code{Inf} the null): \code{C1.L1.path, start,
#'     support_id, nnz, rho, loglik, BIC, MAE} (each after re-estimation on
#'     the proposed support),
#'     \code{pen.value} (the penalized objective reached by that start,
#'     smaller is better) and \code{duplicate} (\code{TRUE} when the same
#'     support was already proposed by an earlier row).}
#'   \item{candidates, supports, support.selected}{One row per distinct
#'     support (\code{support_id, nnz, rho, loglik, BIC, MAE,
#'     lambda1.first, start.first, selected}; the null and the model with
#'     every admitted entry free are always candidates, so
#'     \code{candidates} can hold a support that appears in no row of
#'     \code{path}); the supports themselves
#'     (list of logical \eqn{Q \times P_1} matrices indexed by
#'     \code{support_id}); and the id of the selected one.}
#'   \item{C1.free, C1.L1.path, C1.L1.selected, support}{The free-entry matrix used
#'     (\eqn{Q \times P_1} 0/1), the path, the smallest penalty at which
#'     the selected support was proposed (\code{0} for the unpenalized
#'     model, \code{Inf} for the null), the selected support (logical
#'     \eqn{Q \times P_1}) and the starts used.}
#'   \item{LR, LR.df}{Likelihood-ratio statistics \code{c(full = 2(l_full -
#'     l_null), selected = 2(l_sel - l_null))} and the naive degrees of
#'     freedom \code{c(full = sum(C1.restriction), selected = nnz)} (also stored as
#'     \code{attr(LR, "df")}).  No p-value is attached; see
#'     \code{\link{nmf.ffb.inference}}.}
#'   \item{BIC, AIC}{Named vectors \code{c(null, full, selected)}.}
#'   \item{call}{The matched call, from which
#'     \code{\link{nmf.ffb.inference}} inherits the design.}
#'
#' @examples
#' # Simple NMF-FFB with iris data (non-negative)
#' Y <- t(iris[, -5])
#' Y1 <- Y[1:2, ]  # Sepal
#' Y2 <- Y[3:4, ]  # Petal
#' result <- nmf.ffb(Y1, Y2, rank = 2)
#' result$LR          # feedback vs feed-forward null (no p-value here)
#' result$BIC
#' result$path
#'
#' # Legacy multiplicative-update estimator
#' result.mu <- nmf.ffb(Y1, Y2, rank = 2, maxit = 500, method = "mu")
#' result.mu$mae
#'
#' @seealso \code{\link{nmf.ffb.inference}}, \code{\link{nmf.ffb.cv}},
#'   \code{\link{nmf.ffb.split}}, \code{\link{nmf.ffb.DOT}},
#'   \code{\link{summary.nmf.ffb}}
#' @references
#' Satoh, K. (2025). Applying non-negative matrix factorization with covariates
#'   to structural equation modeling for blind input-output analysis.
#'   arXiv:2512.18250. \url{https://arxiv.org/abs/2512.18250}
#' @export
nmf.ffb <- function(
    Y1, Y2,
    rank = NULL,
    X.init = "nndsvd",
    X.L2.ortho = 100.0,
    C1.L1 = 1.0,
    C2.L1 = 0.1,
    epsilon = 1e-6,
    maxit = 5000,
    seed  = 123,
    ...,
    method = c("fiml", "mu"),
    X = NULL,
    C1.restriction = c("union", "none"),
    C1.restriction.threshold = 0.05,
    Phi.restriction = c("full", "diag"),
    C1.L1.path = NULL,
    select = c("BIC", "none")
) {
  cl <- match.call()
  method <- match.arg(method)
  ## `C1.L1` / `C2.L1` penalize the multiplicative updates.  The likelihood path has
  ## its own penalty -- the grid `C1.L1.path`, swept with the support chosen by BIC --
  ## and leaves Theta2 unpenalized, so these two arguments have no meaning under
  ## method = "fiml".  Until 0.9.8 they were accepted there and silently ignored.
  if (method == "fiml" && any(c("C1.L1", "C2.L1") %in% names(cl)))
    warning("`C1.L1` / `C2.L1` apply to method = \"mu\" only and are ignored here. ",
            "Under method = \"fiml\" the L1 penalty on Theta1 is the path `C1.L1.path` ",
            "(swept, with the support selected by BIC) and Theta2 is unpenalized.",
            call. = FALSE)
  ## Options withdrawn in 0.9.8 would otherwise be swallowed by `...` without a word.
  gone <- c(starts = "every penalized fit is warm-started from the unpenalized fit, the only start measurement ever preferred",
            nsplit = "sample splitting was withdrawn: same size as calibration = \"procedure\", lower power",
            calibration = "the calibration level is an argument of nmf.ffb.test(), not of the fit")
  hit <- base::intersect(base::names(gone), base::names(cl))
  if (length(hit) > 0)
    warning("`", paste(hit, collapse = "`, `"), "` was removed in 0.9.8 and is ignored: ",
            gone[[hit[1]]], ".", call. = FALSE)
  ## A RENAMED argument is the more dangerous case: `mask = "none"` reaches `...`, is
  ## dropped, and the fit silently uses the DEFAULT restriction -- the opposite of the
  ## request.  Stop rather than warn.
  renamed <- c(mask = "C1.restriction", cross.threshold = "C1.restriction.threshold",
               phi = "Phi.restriction", lambda1 = "C1.L1.path")
  hit <- base::intersect(base::names(renamed), base::names(cl))
  if (length(hit) > 0)
    stop("`", paste(hit, collapse = "`, `"), "` was renamed in 0.9.8; use `",
         paste(renamed[hit], collapse = "`, `"),
         "`. It would otherwise be dropped silently and the fit would use the default.",
         call. = FALSE)
  if (method == "mu") {
    ## The anchor options exist for the fiml basis only; the legacy estimator
    ## would drop X.anchor without a word, so refuse both.
    if (!is.null(list(...)$X.anchor) || identical(X.init, "spa"))
      stop("nmf.ffb(): X.anchor and X.init = \"spa\" are available with ",
           "method = \"fiml\" only.", call. = FALSE)
    ## Legacy estimator: the body is unchanged (bit-identical) and lives in
    ## .nmf.ffb.mu(); only the `method` field is appended.
    out <- .nmf.ffb.mu(Y1, Y2, rank = rank, X.init = X.init, X.L2.ortho = X.L2.ortho,
                       C1.L1 = C1.L1, C2.L1 = C2.L1, epsilon = epsilon, maxit = maxit,
                       seed = seed, cl = cl, ...)
    out$method <- "mu"
    return(out)
  }
  if (!is.matrix(C1.restriction)) C1.restriction <- match.arg(C1.restriction)
  Phi.restriction <- match.arg(Phi.restriction)
  select <- match.arg(select)
  ## Each penalized fit is warm-started from the unpenalized full-feedback fit and each support refit from
  ## its own penalized fit.  Alternative start sets were measured and never won (NEWS 0.9.8), so there is
  ## no choice left to expose.
  .nmf.ffb.fiml(Y1, Y2, rank = rank, X.init = X.init, X.L2.ortho = X.L2.ortho,
                epsilon = epsilon, maxit = maxit, seed = seed,
                X = X, C1.restriction = C1.restriction, C1.restriction.threshold = C1.restriction.threshold,
                Phi.restriction = Phi.restriction, C1.L1.path = C1.L1.path, select = select, cl = cl, ...)
}

#' Legacy multiplicative-update NMF-FFB estimator (Internal)
#'
#' The body of \code{nmf.ffb()} as it was before \code{method = "fiml"} was
#' added, moved here verbatim so that \code{nmf.ffb(method = "mu")} stays
#' bit-identical to earlier releases.  \code{cl} is the caller's matched call.
#' @keywords internal
#' @noRd
.nmf.ffb.mu <- function(
    Y1, Y2,
    rank = NULL,
    X.init = "nndsvd",
    X.L2.ortho = 100.0,
    C1.L1 = 1.0,
    C2.L1 = 0.1,
    epsilon = 1e-6,
    maxit = 5000,
    seed  = 123,
    cl = NULL,
    ...
) {
  # ------------------------------ checks ------------------------------
  if (!is.matrix(Y1)) Y1 <- as.matrix(Y1)
  if (!is.matrix(Y2)) Y2 <- as.matrix(Y2)

  if (any(!is.finite(Y1)) || any(!is.finite(Y2)))
    stop("Y1 and Y2 must not contain NA/NaN/Inf.")
  if (min(Y1) < 0 || min(Y2) < 0)
    stop("Y1 and Y2 must be non-negative.")
  if (ncol(Y1) != ncol(Y2))
    stop("ncol(Y1) must be equal to ncol(Y2).")

  extra_args <- list(...)
  Q_hidden <- if (!is.null(extra_args$Q)) extra_args$Q else NULL
  Q0 <- if (!is.null(rank)) rank else if (!is.null(Q_hidden)) Q_hidden else nrow(Y2)

  P1 <- nrow(Y1); P2 <- nrow(Y2); N <- ncol(Y1)
  Q  <- Q0
  if (Q < 1) stop("Rank Q must be >= 1.")

  # -------------------------- labels (output) -------------------------
  Y1_labels    <- if (!is.null(rownames(Y1))) rownames(Y1) else paste0("Y1_", 1:P1)
  Y2_labels    <- if (!is.null(rownames(Y2))) rownames(Y2) else paste0("Y2_", 1:P2)
  Basis_labels <- paste0("Factor", 1:Q)

  ## Keep the self-seeding of this fit out of the caller's random stream.
  ## The guard matters: set.seed(NULL) does not mean "do nothing", it
  ## re-initializes the generator from the clock, so an explicit seed = NULL
  ## fit was irreproducible.  With the guard, seed = NULL means "leave the
  ## stream alone" and the fit follows whatever state the caller is in.
  .rng <- .nmfkc.rng.save(seed)
  on.exit(.nmfkc.rng.restore(.rng), add = TRUE)
  if (!is.null(seed)) set.seed(seed)
  .eps <- 1e-10
  .xnorm  <- function(X) sweep(X, 2, pmax(colSums(X), .eps), "/")
  mat1norm <- function(A) max(colSums(abs(A)))

  # ---------- (optional) internal nmfkc warm-start + SC.map baseline --
  ## Resolution rules for `nmfkc.baseline`:
  ##   * not given (default): if X.init is a string method (or NULL),
  ##     run nmfkc(Y1, A = Y2) INTERNALLY using the same X.init,
  ##     X.L2.ortho, epsilon, maxit, seed; use its X as warm-start
  ##     and X * C as M.simple for SC.map.  This is the typical
  ##     workflow described in Satoh (2025) and means
  ##       res <- nmf.ffb(Y1, Y2, rank = Q)
  ##     can run end-to-end without first calling nmfkc().
  ##   * TRUE: same as above (explicit opt-in even when X.init is a
  ##     user-supplied matrix; the matrix is overridden by nmfkc's X).
  ##   * FALSE: opt-out — no internal nmfkc, no warm-start, SC.map = NA
  ##     (equivalent to pre-v0.6.8 behavior).
  ##   * an nmfkc result (list with $X and $C): use as the baseline
  ##     for SC.map and as warm-start for X.init (no internal call).
  baseline_for_scmap <- NULL

  user_baseline    <- extra_args$nmfkc.baseline
  user_M.simple    <- extra_args$M.simple

  baseline_is_obj  <- is.list(user_baseline) &&
                      !is.null(user_baseline$X) && !is.null(user_baseline$C)
  baseline_is_TRUE <- isTRUE(user_baseline)
  baseline_is_FALSE <- isFALSE(user_baseline)
  baseline_is_default <- is.null(user_baseline)

  ## Default auto-run when baseline is unspecified AND X.init is a string
  ## method (NULL is treated as "nndsvd" string).
  auto_nmfkc <- if (baseline_is_FALSE) {
    FALSE
  } else if (baseline_is_TRUE) {
    TRUE
  } else if (baseline_is_obj) {
    FALSE
  } else if (baseline_is_default) {
    is.null(X.init) || is.character(X.init)
  } else {
    FALSE
  }

  if (auto_nmfkc) {
    ## Internal nmfkc call.  Forward only the genuinely shared options
    ## (X.init, X.L2.ortho, epsilon, maxit, seed); the nmf.ffb-specific
    ## C1.L1 / C2.L1 do not apply to the feedforward baseline model.
    nmfkc_xinit <- if (is.null(X.init)) "nndsvd" else X.init
    baseline_for_scmap <- nmfkc(
      Y = Y1, A = Y2, Q = Q,
      X.init = nmfkc_xinit,
      X.L2.ortho = X.L2.ortho,
      epsilon = epsilon,
      maxit = maxit,
      seed = seed,
      verbose = FALSE,
      print.dims = FALSE
    )
    ## Override X.init with the nmfkc-fitted X for nmf.ffb warm-start
    X.init <- baseline_for_scmap$X
  } else if (baseline_is_obj) {
    baseline_for_scmap <- user_baseline
    ## When the user supplies an nmfkc result AND X.init is still a
    ## string / NULL, also use baseline$X as warm-start (rmd workflow).
    if (is.null(X.init) || is.character(X.init)) {
      X.init <- baseline_for_scmap$X
    }
  }

  # ---------------------------- init X,C1,C2 --------------------------
  ## X.init dispatch: delegate string methods to the shared internal
  ## helper .init_X_method() (defined in R/nmfkc.R).  Accepts:
  ##   "nndsvd" (default), "kmeans", "kmeansar", "runif",
  ##   a numeric P1 x Q matrix, or NULL (alias for "nndsvd").
  if (is.null(X.init)) X.init <- "nndsvd"
  if (is.character(X.init)) {
    X <- .init_X_method(X.init, Y1, Q)
  } else {
    X <- as.matrix(X.init)
    if (!all(dim(X) == c(P1, Q))) {
      stop("X.init must have dimension (nrow(Y1) x rank).")
    }
    X[X < 0] <- 0
  }
  X <- .xnorm(X)

  C1 <- matrix(stats::runif(Q * P1, 0.01, 0.1), nrow = Q, ncol = P1)
  C2 <- matrix(stats::runif(Q * P2, 0.001, 0.01), nrow = Q, ncol = P2)

  min_dim <- min(Q, P2)
  for (i in 1:min_dim) {
    C2[i, i] <- stats::runif(1, 0.1, 0.2)
  }

  objfunc      <- numeric(maxit)
  objfunc.penalized <- numeric(maxit)

  # ----------------------------- main loop ----------------------------
  for (it in 1:maxit) {
    M  <- C1 %*% Y1 + C2 %*% Y2

    # 2.1 update X
    Numerator_X       <- tcrossprod(Y1, M)
    Denominator_X_rec <- tcrossprod(X %*% M, M)

    if (X.L2.ortho > 0) {
      XtX <- t(X) %*% X
      XtX_offdiag <- XtX
      diag(XtX_offdiag) <- 0
      Denominator_X_ortho <- X.L2.ortho * X %*% XtX_offdiag
    } else {
      Denominator_X_ortho <- 0
    }

    X <- X * (Numerator_X / (Denominator_X_rec + Denominator_X_ortho + .eps))
    X <- .xnorm(X)

    Xt  <- t(X)
    XtX <- Xt %*% X

    # 2.2 update C1
    XtY1           <- Xt %*% Y1
    Numerator_C1   <- tcrossprod(XtY1, Y1)
    Denominator_C1 <- tcrossprod(XtX %*% M, Y1) + C1.L1 + .eps
    C1 <- C1 * (Numerator_C1 / Denominator_C1)

    # 2.3 update C2
    Numerator_C2   <- tcrossprod(XtY1, Y2)
    Denominator_C2 <- tcrossprod(XtX %*% (C1 %*% Y1 + C2 %*% Y2), Y2) + C2.L1 + .eps
    C2 <- C2 * (Numerator_C2 / Denominator_C2)

    # loss + penalties
    XB <- X %*% (C1 %*% Y1 + C2 %*% Y2)
    loss_rec <- sum((Y1 - XB)^2)
    objfunc[it] <- loss_rec

    if (X.L2.ortho > 0) {
      XtX_off <- XtX
      diag(XtX_off) <- 0
      pen_X_ortho <- 0.5 * X.L2.ortho * sum(XtX_off^2)
    } else {
      pen_X_ortho <- 0
    }
    pen_C1_L1 <- C1.L1 * sum(C1)
    pen_C2_L1 <- C2.L1 * sum(C2)
    objfunc.penalized[it] <- loss_rec + pen_X_ortho + pen_C1_L1 + pen_C2_L1

    if (it >= 10) {
      epsilon_iter <- abs(objfunc[it] - objfunc[it - 1]) / pmax(abs(objfunc[it]), 1)
      if (epsilon_iter <= epsilon) break
    }
  }
  ## Warn when the MU loop exhausts maxit without meeting the
  ## relative-tolerance criterion (matches the nmfkc() convention).
  if (it == maxit && exists("epsilon_iter") && epsilon_iter > abs(epsilon))
    warning(paste0("maximum iterations (", maxit, ") reached..."))

  # ------------------ reorder factors (nmfkc centroid order) ----------
  centroid <- as.numeric((1:nrow(X)) / nrow(X)) %*% X
  index <- order(centroid)
  X  <- X[, index, drop = FALSE]
  C1 <- C1[index, , drop = FALSE]
  C2 <- C2[index, , drop = FALSE]

  # ------------------------------ names -------------------------------
  colnames(X)  <- Basis_labels
  rownames(C1) <- Basis_labels
  rownames(C2) <- Basis_labels
  rownames(X)  <- Y1_labels
  colnames(C1) <- Y1_labels
  colnames(C2) <- Y2_labels

  # -------------------- feedback + stability diagnostics --------------
  XC1  <- X %*% C1
  eigs <- eigen(XC1, only.values = TRUE)$values
  rho  <- max(abs(eigs))
  if (rho >= 1)
    warning("Leontief.inv may be unstable; spectral radius >= 1.")

  XC1_norm1 <- mat1norm(XC1)

  # -------------------- Leontief inverse + equilibrium mapping --------
  I_mat <- diag(nrow(XC1))
  XC2   <- X %*% C2

  Leontief.inv <- tryCatch(
    base::solve(I_mat - XC1),
    error = function(e) {
      warning("Failed to compute Leontief.inv via solve(I - XC1). Returning NA matrices.")
      matrix(NA_real_, nrow = nrow(XC1), ncol = ncol(XC1))
    }
  )
  M.model <- Leontief.inv %*% XC2

  amplification <- mat1norm(M.model) / (mat1norm(XC2) + .eps)
  amplification.bound <- if (XC1_norm1 < 1) 1 / (1 - XC1_norm1) else Inf

  # -------------------- fit indices (equilibrium prediction) ----------
  if (anyNA(Leontief.inv)) {
    Y1_hat <- matrix(NA_real_, nrow = nrow(Y1), ncol = ncol(Y1))
    SC.cov <- NA_real_
    MAE    <- NA_real_
  } else {
    Y1_hat   <- M.model %*% Y2
    S.sample <- Y1 %*% t(Y1)
    S.model  <- Y1_hat %*% t(Y1_hat)
    SC.cov   <- stats::cor(as.numeric(S.sample), as.numeric(S.model))
    MAE      <- mean(abs(Y1 - Y1_hat))
  }

  ## -------------------- input-output structural fidelity (SC.map) -----
  ## SC.map = cor(vec(M.model), vec(M.simple)), where M.simple = X0 * Theta0
  ## is the feedforward baseline mapping (Satoh 2025 §4.SC.map).
  ## `baseline_for_scmap` was set above to either:
  ##   - the result of an internal nmfkc call (default auto path), or
  ##   - the user-supplied `nmfkc.baseline` list, or
  ##   - NULL if the user opted out (nmfkc.baseline = FALSE) or
  ##     supplied X.init as a numeric matrix without nmfkc.baseline.
  ## A user-supplied `M.simple` matrix takes precedence over both.
  SC.map <- NA_real_
  M.simple <- if (!is.null(user_M.simple)) user_M.simple
              else if (!is.null(baseline_for_scmap))
                baseline_for_scmap$X %*% baseline_for_scmap$C
              else NULL
  if (!is.null(M.simple) && !anyNA(M.model)) {
    M.simple <- as.matrix(M.simple)
    if (all(dim(M.simple) == dim(M.model))) {
      SC.map <- tryCatch(
        stats::cor(as.numeric(M.simple), as.numeric(M.model)),
        error = function(e) NA_real_
      )
    } else {
      warning("M.simple has dimension ", paste(dim(M.simple), collapse = "x"),
              " but M.model is ", paste(dim(M.model), collapse = "x"),
              "; SC.map not computed.")
    }
  }

  out <- list(
    ## call / dims match the other fitters so print.nmf() has a header to show.
    call                = cl,
    dims                = sprintf("Y1(%d,%d)~X(%d,%d)[C1(%d,%d)Y1+C2(%d,%d)Y2]",
                                  nrow(Y1), ncol(Y1), nrow(X), ncol(X),
                                  ncol(X), nrow(Y1), ncol(X), nrow(Y2)),
    X                   = X,
    C1                  = C1,
    C2                  = C2,
    XC1                 = XC1,
    XC2                 = XC2,
    XC1.radius          = rho,
    XC1.norm1           = XC1_norm1,
    Leontief.inv        = Leontief.inv,
    M.model             = M.model,
    amplification       = amplification,
    amplification.bound = amplification.bound,
    rank                = Q,
    SC.cov              = SC.cov,
    SC.map              = SC.map,
    mae                 = MAE,
    ## Effective rank of the latent scores B = C1 Y1 + C2 Y2 (Q x N).
    effective.rank      = .effective.rank(C1 %*% Y1 + C2 %*% Y2),
    objfunc             = objfunc[1:it],
    objfunc.penalized        = objfunc.penalized[1:it],
    iter                = it,
    ## Convergence bookkeeping, matching nmfkc / nmfre / nmfae so print.nmf()
    ## and the summaries can say whether the run finished or hit the cap.
    maxit               = maxit,
    epsilon             = epsilon,
    converged           = !(it == maxit && exists("epsilon_iter") &&
                            epsilon_iter > abs(epsilon))
  )
  class(out) <- c("nmf.ffb", "nmf")
  out
}


#' @title Bootstrap inference for NMF-FFB (X held fixed)
#' @description
#' \code{nmf.ffb.inference} performs statistical inference on the structural
#' coefficient matrices \eqn{C_1} (latent feedback, \eqn{\Theta_1}) and
#' \eqn{C_2} (exogenous loading, \eqn{\Theta_2}) from a fitted
#' \code{\link{nmf.ffb}} model, \emph{after} the feed-forward null has been
#' rejected by \code{\link{nmf.ffb.test}}.  The
#' basis \eqn{\hat X} is held fixed throughout, which avoids label switching
#' and gives a clean conditional interpretation: uncertainty of the structural
#' coefficients given the measurement model.  Which resampling scheme is run
#' depends on \code{object$method}.
#'
#' \strong{\code{method = "fiml"}: a parametric bootstrap from the selected
#' model.}  \eqn{B} data sets are drawn from the selected model (with its
#' \eqn{\Theta_1}) and \eqn{(\Theta_1, \Theta_2, \Phi, \psi)} are re-estimated
#' with \eqn{X} and the selected support held fixed.  Confidence intervals are
#' the centred (basic) percentile intervals \eqn{[2\hat\theta -
#' q^*_{1-\alpha/2},\; 2\hat\theta - q^*_{\alpha/2}]}; \code{support_rate} is
#' the share of replicates with \eqn{|\hat\theta^*| >} \code{threshold} (0 for
#' entries outside the selected support), and \code{prob.unsupported} is
#' \eqn{1 - \mathrm{support\_rate}}.
#'
#' These are \emph{post-selection} quantities, conditional on the support that
#' BIC picked, and they can overstate the evidence.  They answer "how large is
#' this entry, given that the procedure kept it", not "is there feedback at
#' all"; the calibrated statement about the presence of feedback is
#' \code{\link{nmf.ffb.test}}, which is the step that belongs before this one.
#' Each replicate seeds itself (\code{seed + B + b}), so the result does not
#' depend on \code{cores}.
#'
#' \strong{\code{method = "mu"} (legacy fits, and objects without a
#' \code{method} field).}  The procedure is a \strong{full pair bootstrap} that holds the basis
#' matrix \eqn{\hat X} from the original fit fixed across all replicates
#' (which avoids label switching and gives a clean conditional
#' interpretation: ``uncertainty of the structural coefficients given the
#' measurement model''):
#' \enumerate{
#'   \item For each replicate \eqn{b = 1, \dots, B}, resample column indices
#'     \eqn{(i_1, \dots, i_N)} with replacement from \eqn{\{1, \dots, N\}}
#'     and form \eqn{Y_1^{(b)} = Y_1[, i]}, \eqn{Y_2^{(b)} = Y_2[, i]}.
#'   \item Re-estimate \eqn{(C_1^{(b)}, C_2^{(b)})} by running the
#'     \code{\link{nmf.ffb}} multiplicative updates \emph{with \eqn{X = \hat X}
#'     held fixed} (no \eqn{X} update; no centroid sort), using the same
#'     \code{C1.L1}, \code{C2.L1} as the original fit.
#'   \item Discard replicates that violate stationarity
#'     (\eqn{\rho(X C_1^{(b)}) \ge 1}) or have an amplification ratio
#'     exceeding the geometric-series bound by more than 1\%.
#' }
#' Because \eqn{C_1, C_2 \ge 0} are non-negative by construction, exact
#' zeros are essentially never observed in the bootstrap distribution.
#' Significance is assessed via a \strong{support rate} at a small display
#' threshold \eqn{\delta} (default \code{0.01}):
#' \deqn{
#'   \mathrm{sup}(c) \;=\; \frac{1}{|\mathrm{valid}|}
#'     \sum_{b \in \mathrm{valid}} \mathbf{1}\!\left( \hat c^{(b)} > \delta \right).
#' }
#' This is a one-sided counterpart of the classical \eqn{p}-value:
#' large \code{support_rate} indicates strong evidence that the entry is
#' meaningfully positive.  Significance markers follow the lavaan
#' convention with the natural correspondence \eqn{p = 1 - \mathrm{sup}}:
#' \code{*} (sup > 0.95), \code{**} (sup > 0.99), \code{***}
#' (sup > 0.999).  Cutoffs use strict greater-than so the rule
#' mirrors the standard R convention for p-values (p < 0.05 / 0.01 /
#' 0.001 → */**/***), translated to support_rate via
#' \eqn{\mathrm{sup} = 1 - p}.
#'
#' @param object A fitted object returned by \code{\link{nmf.ffb}}.  Must
#'   contain \code{X}, \code{C1}, \code{C2}.
#' @param Y1 Endogenous variable matrix (P1 x N).  Must match the data
#'   used in \code{nmf.ffb()}.
#' @param Y2 Exogenous variable matrix (P2 x N).  Same.
#' @param B Number of bootstrap replicates.  Default \code{1000}, the value the
#'   published analysis used; the other inference functions in the package
#'   default their \code{wild.B} to 500, and this one is deliberately left at
#'   1000 so the manuscript results reproduce out of the box.  Note that
#'   \code{***} (sup > 0.999) is only reachable when \emph{every} replicate
#'   clears \code{threshold}, at any \code{B}: raising \code{B} does not add a
#'   finer grade, it makes the same grade stronger evidence (all 1000 rather
#'   than all 500).  Lowering it to 500 roughly halves the running time, since
#'   each replicate is a re-fit.  For \code{method = "fiml"} a replicate is one
#'   FIML fit on the selected support, so \code{B = 1000} is affordable; the
#'   expensive bootstrap is the null one, which \code{\link{nmf.ffb.test}} runs.
#' @param threshold Display threshold \eqn{\delta} for the support rate
#'   \eqn{\Pr_{\mathrm{boot}}(\hat c^{(b)} > \delta)}.  Default
#'   \code{0.01}; entries below this magnitude are treated as effectively
#'   zero in the path diagram.
#' @param boot.level Confidence level for the bootstrap CI.
#'   Default \code{0.95}.
#' @param C1.L1,C2.L1 L1 sparsity penalties used by the original
#'   \code{method = "mu"} fit.  These must match the fit's hyperparameters
#'   for the bootstrap to estimate the correct model.  Defaults
#'   (\code{1.0}, \code{0.1}) match \code{nmf.ffb}'s defaults but you
#'   should pass the actual values used.  Not used for \code{method = "fiml"}.
#' @param seed Base RNG seed for the bootstrap.  For \code{method = "mu"}
#'   each replicate uses \code{seed + b} (resampling) and
#'   \code{seed + 1000 + b} (\eqn{C_1, C_2} initialization); for
#'   \code{method = "fiml"} see Description.  Default \code{123}.
#' @param ... Hidden options.  Shared: \code{cores} (number of parallel
#'   workers, default \code{getOption("mc.cores", 1L)} for \code{"fiml"},
#'   \code{1} for \code{"mu"}; \code{ncores} is accepted as an alias) and
#'   \code{print.trace}.  For \code{method = "fiml"}: \code{factr} and
#'   \code{fiml.maxit} (L-BFGS-B tolerance and cap, default the values
#'   recorded on \code{object}).  The null bootstrap is no longer run here at
#'   all: see \code{\link{nmf.ffb.test}}.  (Removed: \code{boot.null}, which
#'   used to skip it, and the coefficient
#'   intervals).  For \code{method = "mu"}:
#'   \describe{
#'     \item{\code{epsilon}}{Convergence tolerance for the inner fixed-X MU
#'       loop.  Default \code{1e-8} -- deliberately tighter than a plain fit,
#'       because under multiplicative updates an entry heading for the
#'       non-negativity boundary approaches 0 slowly, so a loose tolerance
#'       stops every replicate while such an entry is still above
#'       \code{threshold}.  The support rate is then inflated and significance
#'       over-declared: on a pure-noise design the support of null entries
#'       falls from 0.75 to 0.20 to 0.017 as \code{epsilon} goes 1e-6, 1e-8,
#'       1e-10, while genuinely supported entries stay at 1.  Tighten further
#'       when near-threshold entries matter; loosen only for speed.}
#'     \item{\code{maxit}}{Maximum iterations for the inner MU loop.
#'       Default \code{100000} (the tighter tolerance needs the headroom).}
#'   }
#'
#' @return The input \code{object} with additional bootstrap inference
#'   components:
#' \item{coefficients}{Data frame with rows for every entry of \eqn{C_1}
#'   and \eqn{C_2} and columns \code{Type} ("C1" / "C2"), \code{Basis},
#'   \code{Covariate}, \code{Estimate}, \code{CI_low}, \code{CI_high},
#'   \code{support_rate}, \code{prob.unsupported} (\eqn{= 1 -
#'   \mathrm{support\_rate}}) and \code{sig}.}
#' \item{C1.support.rate, C2.support.rate}{Per-element support rates
#'   (Q x P1 and Q x P2 matrices).}
#' \item{C1.ci.lower, C1.ci.upper, C2.ci.lower, C2.ci.upper}{Per-element
#'   CI bounds (percentile for \code{"mu"}, centred percentile for
#'   \code{"fiml"}).}
#' \item{C1.boot.draws, C2.boot.draws}{Bootstrap distributions: 3D arrays of shape
#'   B x Q x P1 (and B x Q x P2).  Invalid replicates contain \code{NA}.}
#' \item{rho.boot.draws}{Per-replicate spectral radius \eqn{\rho(X C_1^*)}.}
#' \item{AR.boot, iter.boot}{(\code{"mu"} only) per-replicate amplification
#'   ratio and inner-loop iteration count.}
#' #' \item{boot.B, boot.threshold, boot.level}{Inputs recorded for
#'   reproducibility.}
#' \item{boot.n.valid, boot.n.invalid}{Validity counts (for \code{"fiml"}:
#'   of the selected-model bootstrap).}
#' \item{boot.method}{How the replicates were drawn.}
#' \item{identified.approx, identification}{Whether \eqn{(X, [C_1\ C_2])} is
#'   unique up to a permutation of the bases -- the diagnostic of
#'   \code{\link{nmfkc.inference}} with \eqn{A = [Y_1; Y_2]} (see the Details of
#'   \code{\link{nmfkc.ar.latent.inference}}).  The inference is conditional on
#'   \eqn{\hat X}; when \code{TRUE} it concerns the same coefficients whichever
#'   admissible representative is taken.  The exclusion restriction's zeros in
#'   \eqn{C_1} count.  An algebraic check on the point estimate: no re-fits.}
#'
#' The calibrated test of the feed-forward null -- \code{LR.p.boot},
#' \code{prob.select.null}, \code{C1.restriction.change.rate} and the null
#' bootstrap behind them -- is \strong{not} returned here.  It moved to
#' \code{\link{nmf.ffb.test}} in 0.9.8, so that an object carrying coefficient
#' intervals cannot be mistaken for a test of feedback (see NEWS).
#'
#' @section Lifecycle:
#' This function's interface changed at v0.6.8: the legacy 1-step Newton
#' wild bootstrap (with sandwich SE) has been replaced by the full pair
#' bootstrap described above, following the paper revision.  The fields
#' \code{sigma2.used}, \code{C2.se}, \code{C2.se.boot}, \code{C2.p.side}
#' that the previous implementation produced are no longer present.
#'
#' @seealso \code{\link{nmf.ffb}}, \code{\link{nmf.ffb.DOT}}
#' @references
#' Satoh, K. (2025). Applying non-negative matrix factorization with covariates
#'   to structural equation modeling for blind input-output analysis.
#'   arXiv:2512.18250. \url{https://arxiv.org/abs/2512.18250}
#' @export
#' @examples
#' \donttest{
#' Y <- t(iris[, -5])
#' Y1 <- Y[1:2, ]; Y2 <- Y[3:4, ]
#' res  <- nmf.ffb(Y1, Y2, rank = 2)
## The test of the feed-forward null is nmf.ffb.test(); this function gives
#' ## intervals for the entries the test has already licensed reading.
#' inf <- nmf.ffb.inference(res, Y1, Y2, B = 20)   # use B = 1000 in practice
#' head(inf$coefficients)  # estimate, interval and support rate per entry
#'
#' res.mu  <- nmf.ffb(Y1, Y2, rank = 2, method = "mu")
#' res.mu2 <- nmf.ffb.inference(res.mu, Y1, Y2, B = 200)
#' head(res.mu2$coefficients)
#' }
nmf.ffb.inference <- function(object, Y1, Y2,
                               B = 1000L,
                               threshold = 0.01,
                               boot.level = 0.95,
                               C1.L1 = 1.0,
                               C2.L1 = 0.1,
                               seed = 123L,
                               ...) {
  if (is.null(object$X) || is.null(object$C1) || is.null(object$C2))
    stop("object must contain X, C1, and C2 (returned by nmf.ffb).")
  ## Renamed in 0.9.8; through `...` it would be dropped and the default used.
  if ("ci.level" %in% names(match.call()))
    stop("`ci.level` was renamed to `boot.level` in 0.9.8.", call. = FALSE)

  ## Likelihood-based fits take the parametric-bootstrap branch; objects
  ## without a `method` field predate it and are multiplicative-update fits.
  if (identical(object$method, "fiml"))
    return(.ffb.attach.ident(
      .nmf.ffb.inference.fiml(object, Y1, Y2, B = B, threshold = threshold,
                              boot.level = boot.level, seed = seed, what = "ci", ...),
      Y1, Y2, base::list(...)))

  extra_args  <- base::list(...)
  ## Keep our own seeding out of the caller's random stream.
  .rng <- .nmfkc.rng.save(seed)
  on.exit(.nmfkc.rng.restore(.rng), add = TRUE)
  ## Convergence control of the fixed-X re-fits.  Deliberately tighter than a
  ## plain fit: under multiplicative updates a coefficient heading for the
  ## non-negativity boundary approaches 0 slowly, so a loose tolerance stops
  ## every replicate while such an entry is still above the display threshold.
  ## The support rate is then inflated and significance over-declared -- on a
  ## pure-noise design the support of null entries falls 0.75 -> 0.20 -> 0.017
  ## as epsilon goes 1e-6 -> 1e-8 -> 1e-10, while genuinely supported entries
  ## stay at 1.  Tighten further if near-threshold entries matter.
  epsilon     <- if (!is.null(extra_args$epsilon))     extra_args$epsilon     else 1e-8
  maxit       <- if (!is.null(extra_args$maxit))       extra_args$maxit       else 100000L
  ## `cores` is the house name (CONVENTIONS.md 4); `ncores` stays as the alias.
  ncores      <- if (!is.null(extra_args$cores))       extra_args$cores
                 else if (!is.null(extra_args$ncores)) extra_args$ncores else 1L
  print.trace <- if (!is.null(extra_args$print.trace)) extra_args$print.trace else FALSE

  Y1 <- base::as.matrix(Y1)
  Y2 <- base::as.matrix(Y2)
  if (ncol(Y1) != ncol(Y2)) stop("Y1 and Y2 must have the same number of columns.")

  X.hat  <- object$X
  C1.hat <- object$C1
  C2.hat <- object$C2

  Q  <- ncol(X.hat)
  P1 <- nrow(Y1)
  P2 <- nrow(Y2)
  N  <- ncol(Y1)
  if (nrow(X.hat) != P1)
    stop("nrow(X) must equal nrow(Y1); did Y1 change shape?")
  if (Q < 1L) stop("Inferred rank Q < 1.")

  ## ----------------------------------------------------------------
  ## one_boot: a single bootstrap replicate.
  ## Closure captures Y1, Y2, X.hat, Xt, XtX, C1.L1, C2.L1, Q, P1, P2,
  ## N, epsilon, maxit, seed.  Returns a list with valid flag plus
  ## (C1, C2, rho, AR, iter) when valid, or NA placeholders otherwise.
  ## X.hat is fixed by design, so Xt / XtX are replicate-invariant and
  ## computed once here rather than inside every replicate.
  ## ----------------------------------------------------------------
  Xt  <- t(X.hat)
  XtX <- Xt %*% X.hat
  one_boot <- function(b) {
    ## Sample column indices with replacement (pair bootstrap)
    set.seed(seed + b)
    idx  <- sample.int(N, size = N, replace = TRUE)
    Y1_b <- Y1[, idx, drop = FALSE]
    Y2_b <- Y2[, idx, drop = FALSE]

    ## Independent random init for C1, C2 (X is held at X.hat)
    set.seed(seed + 1000L + b)
    C1 <- matrix(stats::runif(Q * P1, 0.01, 0.1),  nrow = Q)
    C2 <- matrix(stats::runif(Q * P2, 0.001, 0.01), nrow = Q, ncol = P2)
    min_dim <- min(Q, P2)
    if (min_dim >= 1L) {
      for (i in 1:min_dim) C2[i, i] <- stats::runif(1, 0.1, 0.2)
    }

    .eps_local <- 1e-10
    XtY1_b  <- Xt %*% Y1_b              # Q x N (re-used via crossprods below)
    XtY1Y1t <- tcrossprod(XtY1_b, Y1_b) # Q x P1   = Xt %*% Y1_b %*% t(Y1_b)
    XtY1Y2t <- tcrossprod(XtY1_b, Y2_b) # Q x P2
    prev_loss <- Inf
    iter_used <- 0L

    ## Fixed-X MU loop on (C1, C2).  M_b = C1 Y1_b + C2 Y2_b is computed
    ## before the loop and carried across iterations: the value formed after
    ## the C2 update (used for the loss) is exactly the loop-top value of the
    ## next iteration, so each iteration computes it twice instead of three
    ## times with identical operands.
    M_b <- C1 %*% Y1_b + C2 %*% Y2_b     # Q x N latent representation
    for (it in seq_len(maxit)) {
      ## C1 update
      Den_C1 <- tcrossprod(XtX %*% M_b, Y1_b) + C1.L1 + .eps_local
      C1 <- C1 * (XtY1Y1t / Den_C1)

      ## C2 update (uses updated C1 via fresh M_b)
      M_b <- C1 %*% Y1_b + C2 %*% Y2_b
      Den_C2 <- tcrossprod(XtX %*% M_b, Y2_b) + C2.L1 + .eps_local
      C2 <- C2 * (XtY1Y2t / Den_C2)

      ## Convergence check (relative change in reconstruction loss)
      M_b <- C1 %*% Y1_b + C2 %*% Y2_b   # new C1, new C2; carried to next iter
      XB <- X.hat %*% M_b
      loss <- sum((Y1_b - XB)^2)
      if (it >= 10L) {
        rel <- abs(loss - prev_loss) / max(abs(loss), 1)
        if (rel <= epsilon) { iter_used <- it; break }
      }
      prev_loss <- loss
      iter_used <- it
    }

    ## ---- Validity checks (mirrors the paper's bootstrap helper) ----
    XC1_b <- X.hat %*% C1
    rho <- tryCatch(
      max(abs(eigen(XC1_b, only.values = TRUE, symmetric = FALSE)$values)),
      error = function(e) NA_real_
    )
    norm1_XC1 <- max(colSums(abs(XC1_b)))
    AR_bound <- if (is.finite(norm1_XC1) && norm1_XC1 < 1)
                  1 / (1 - norm1_XC1)
                else Inf

    if (!is.finite(rho) || rho >= 1) {
      return(list(valid = FALSE, C1 = NULL, C2 = NULL,
                  rho = rho, AR = NA_real_, iter = iter_used))
    }
    ## XC1_b = X (P1 x Q) %*% C1 (Q x P1) is P1 x P1, so the identity
    ## here must be P1 x P1 too.  (Earlier draft used diag(Q) which is
    ## conformable only when Q == P1, otherwise solve() throws an error
    ## that the tryCatch swallowed -- causing every replicate to be
    ## marked invalid with AR = NA.)
    I_mat   <- diag(nrow(XC1_b))
    Linv    <- tryCatch(solve(I_mat - XC1_b), error = function(e) NULL)
    if (is.null(Linv)) {
      return(list(valid = FALSE, C1 = NULL, C2 = NULL,
                  rho = rho, AR = NA_real_, iter = iter_used))
    }
    XC2_b   <- X.hat %*% C2
    M.model <- Linv %*% XC2_b
    AR <- max(colSums(abs(M.model))) / (max(colSums(abs(XC2_b))) + .eps_local)
    if (!is.finite(AR) || AR > AR_bound * 1.01) {
      return(list(valid = FALSE, C1 = NULL, C2 = NULL,
                  rho = rho, AR = AR, iter = iter_used))
    }

    list(valid = TRUE, C1 = C1, C2 = C2,
         rho = rho, AR = AR, iter = iter_used)
  }

  ## ----------------------------------------------------------------
  ## Run the B replicates (parallel on Linux/macOS via mclapply,
  ## PSOCK cluster on Windows; serial when ncores == 1).
  ## ----------------------------------------------------------------
  if (print.trace)
    base::message(sprintf("  Bootstrap: B=%d, ncores=%d, threshold=%.3g, boot.level=%.2f",
                          B, ncores, threshold, boot.level))

  if (ncores > 1L) {
    if (.Platform$OS.type == "windows") {
      cl <- parallel::makeCluster(ncores)
      on.exit(parallel::stopCluster(cl), add = TRUE)
      parallel::clusterExport(cl,
        varlist = c("Y1", "Y2", "X.hat", "Xt", "XtX", "C1.L1", "C2.L1",
                    "Q", "P1", "P2", "N", "epsilon", "maxit", "seed"),
        envir = environment())
      res_list <- parallel::parLapply(cl, seq_len(B), one_boot)
    } else {
      res_list <- parallel::mclapply(seq_len(B), one_boot, mc.cores = ncores)
    }
  } else {
    res_list <- lapply(seq_len(B), one_boot)
  }

  ## ----------------------------------------------------------------
  ## Aggregate replicates into 3D arrays (B x Q x P1) and (B x Q x P2)
  ## ----------------------------------------------------------------
  C1.boot.draws <- array(NA_real_, dim = c(B, Q, P1))
  C2.boot.draws <- array(NA_real_, dim = c(B, Q, P2))
  rho.vec  <- rep(NA_real_, B)
  AR.vec   <- rep(NA_real_, B)
  iter.vec <- rep(NA_integer_, B)
  valid.vec <- logical(B)

  for (b in seq_len(B)) {
    r <- res_list[[b]]
    if (is.null(r) || inherits(r, "try-error")) next
    valid.vec[b] <- isTRUE(r$valid)
    if (!is.null(r$rho))  rho.vec[b]  <- r$rho
    if (!is.null(r$AR))   AR.vec[b]   <- r$AR
    if (!is.null(r$iter)) iter.vec[b] <- as.integer(r$iter)
    if (valid.vec[b]) {
      C1.boot.draws[b, , ] <- r$C1
      C2.boot.draws[b, , ] <- r$C2
    }
  }
  n.valid <- sum(valid.vec)

  if (n.valid < 10L) {
    warning(sprintf("Only %d / %d bootstrap replicates were valid; CIs / support rates may be unreliable. Consider checking convergence (raise maxit, lower epsilon) or the stationarity of the original fit (rho < 1).",
                    n.valid, B))
  }

  ## ----------------------------------------------------------------
  ## Per-element summary: support rate at threshold, percentile CI
  ## ----------------------------------------------------------------
  alpha <- 1 - boot.level

  apply_finite <- function(arr, FUN) {
    apply(arr, c(2, 3), function(v) {
      v <- v[is.finite(v)]
      if (!length(v)) NA_real_ else FUN(v)
    })
  }

  C1.support  <- apply_finite(C1.boot.draws, function(v) mean(v > threshold))
  C2.support  <- apply_finite(C2.boot.draws, function(v) mean(v > threshold))
  C1.ci.lower <- apply_finite(C1.boot.draws, function(v) stats::quantile(v, alpha / 2,    names = FALSE))
  C1.ci.upper <- apply_finite(C1.boot.draws, function(v) stats::quantile(v, 1 - alpha / 2, names = FALSE))
  C2.ci.lower <- apply_finite(C2.boot.draws, function(v) stats::quantile(v, alpha / 2,    names = FALSE))
  C2.ci.upper <- apply_finite(C2.boot.draws, function(v) stats::quantile(v, 1 - alpha / 2, names = FALSE))

  ## Significance markers from support rate (one-sided; lavaan / stats
  ## convention).  Cutoffs use strict greater-than so the rule mirrors
  ## the R convention for p-values (p < 0.05 / 0.01 / 0.001 → */**/***),
  ## translated to support_rate via support_rate = 1 - p.  This matches
  ## the verbal description "support rates exceeding 0.95, 0.99, 0.999"
  ## in Satoh (2025), arXiv:2512.18250.
  sig.from.support <- function(s) {
    ifelse(!is.finite(s), " ",
      ifelse(s > 0.999, "***",
        ifelse(s > 0.99,  "**",
          ifelse(s > 0.95, "*", " "))))
  }

  ## ----------------------------------------------------------------
  ## Build coefficients table (rows for both C1 and C2)
  ##
  ## `1 - support_rate` is the fraction of bootstrap replicates in which the
  ## coefficient was NOT supported.  That is a tail proportion, not a p-value,
  ## and CONVENTIONS.md section 6 forbids naming such a quantity `p.*` for
  ## exactly this reason -- calling it p_value invites reading 0.03 as
  ## significance at the 5% level, which it is not.  The column is therefore
  ## now `prob.unsupported`, with `p_value` kept alongside it as a
  ## back-compatible duplicate: nmf.ffb.DOT and any user script that filters or
  ## stars on p_value keep working unchanged.
  ## ----------------------------------------------------------------
  Q_lab  <- if (!is.null(rownames(C1.hat))) rownames(C1.hat) else paste0("Factor", 1:Q)
  Y1_lab <- if (!is.null(colnames(C1.hat))) colnames(C1.hat) else paste0("Y1_", 1:P1)
  Y2_lab <- if (!is.null(colnames(C2.hat))) colnames(C2.hat) else paste0("Y2_", 1:P2)

  build_block <- function(type_label, Mhat, sup, lo, hi, basislabs, varlabs) {
    Q_local <- nrow(Mhat); P_local <- ncol(Mhat)
    s_vec <- as.vector(sup)
    data.frame(
      Type         = type_label,
      Basis        = rep(basislabs, times = P_local),
      Covariate    = rep(varlabs,   each  = Q_local),
      Estimate     = as.vector(Mhat),
      CI_low       = as.vector(lo),
      CI_high      = as.vector(hi),
      support_rate = s_vec,
      ## the convention-compliant name ...
      prob.unsupported = ifelse(is.finite(s_vec), 1 - s_vec, NA_real_),
      ## ... and the historical one, same numbers, kept for back-compatibility
      sig          = sig.from.support(s_vec),
      stringsAsFactors = FALSE
    )
  }

  C1_block <- build_block("C1", C1.hat, C1.support, C1.ci.lower, C1.ci.upper, Q_lab, Y1_lab)
  C2_block <- build_block("C2", C2.hat, C2.support, C2.ci.lower, C2.ci.upper, Q_lab, Y2_lab)
  coefficients <- rbind(C1_block, C2_block)
  rownames(coefficients) <- NULL

  if (print.trace)
    base::message(sprintf("  Bootstrap done: %d / %d valid replicates.", n.valid, B))

  ## ----------------------------------------------------------------
  ## Append to object and return
  ## ----------------------------------------------------------------
  object$boot.B          <- B
  object$boot.threshold  <- threshold
  object$boot.level           <- boot.level
  object$boot.n.valid    <- n.valid
  object$boot.n.invalid  <- B - n.valid
  object$rho.boot.draws             <- rho.vec
  object$AR.boot              <- AR.vec
  object$iter.boot            <- iter.vec
  object$C1.boot.draws             <- C1.boot.draws
  object$C2.boot.draws             <- C2.boot.draws
  object$C1.support.rate      <- C1.support
  object$C2.support.rate      <- C2.support
  object$C1.ci.lower          <- C1.ci.lower
  object$C1.ci.upper          <- C1.ci.upper
  object$C2.ci.lower          <- C2.ci.lower
  object$C2.ci.upper          <- C2.ci.upper
  object$coefficients         <- coefficients
  object <- .ffb.attach.ident(object, Y1, Y2, extra_args)

  ## Final class vector for a typical input:
  ##   c("nmf.ffb.inference", "nmf.inference", "nmf.ffb", "nmf")
  if (!inherits(object, "nmf.ffb.inference"))
    ## "nmf.inference" too, so print() reaches print.nmf.inference -> the
    ## coefficient table.  Without it the class chain ended at "nmf" and
    ## printing an inference result showed only the fit header.
    class(object) <- c("nmf.ffb.inference", "nmf.inference", class(object))
  object
}


#' @title Cross-Validation for NMF-FFB
#' @description
#' Performs K-fold cross-validation to evaluate the equilibrium mapping of
#' the NMF-FFB model.
#'
#' For each fold, \code{nmf.ffb} is fitted on the training samples,
#' yielding an equilibrium mapping \eqn{\hat Y_1 = M_{\mathrm{model}} Y_2}.
#' The held-out endogenous variables \eqn{Y_1} are then predicted from \eqn{Y_2}
#' using this mapping, and the mean absolute error (MAE) over all entries in the
#' test block is computed. The returned value is the average MAE across folds.
#'
#' This implements the hyperparameter selection strategy described in the paper:
#' hyperparameters are chosen by predictive cross-validation rather than direct
#' inspection of the internal structural matrices.
#'
#' \strong{\code{method = "fiml"}.}  When \code{method = "fiml"} is passed
#' (through \code{...}), the column-wise scheme above is not run: the
#' equilibrium prediction \eqn{M_{\mathrm{model}} Y_2} is a reduced-form
#' quantity, and the reduced form is the same with and without feedback, so
#' predicting held-out columns cannot select \eqn{\Theta_1} (that is what the
#' BIC path and the bootstrap-calibrated LR test in \code{\link{nmf.ffb}} /
#' \code{\link{nmf.ffb.inference}} are for).  The only tunable quantity is the
#' stage-1 rank, and the call is delegated to the element-wise CV of the
#' feed-forward fit, \code{\link{nmfkc.ecv}(Y = Y1, A = Y2, rank = rank,
#' ...)}, whose object is returned (\code{$objfunc} holds the CV error per
#' rank; \code{rank} may be a vector, default \code{1:min(3, P1)}).
#' \code{C1.L1} and \code{C2.L1} are ignored in that case.
#'
#' @param Y1 A non-negative numeric matrix of endogenous variables with
#'   \strong{rows = variables (P1), columns = samples (N)}.
#' @param Y2 A non-negative numeric matrix of exogenous variables with
#'   \strong{rows = variables (P2), columns = samples (N)}.
#'   Must satisfy \code{ncol(Y1) == ncol(Y2)}.
#' @param rank Integer; rank (number of latent factors) passed to \code{nmf.ffb}.
#'   If \code{NULL}, \code{nmf.ffb} decides the effective rank (via \code{...} or \code{nrow(Y2)}).
#' @param X.init Initialization strategy for \code{X}, forwarded to
#'   \code{\link{nmf.ffb}}.  One of \code{"nndsvd"} (default),
#'   \code{"kmeans"}, \code{"kmeansar"}, \code{"runif"}, a numeric
#'   \eqn{P_1 \times Q} matrix, or \code{NULL} (alias for
#'   \code{"nndsvd"}).  See \code{\link{nmf.ffb}} for details.
#' @param X.L2.ortho L2 orthogonality penalty for \code{X}.
#' @param C1.L1 L1 sparsity penalty for \code{C1} (\eqn{\Theta_1}).
#' @param C2.L1 L1 sparsity penalty for \code{C2} (\eqn{\Theta_2}).
#' @param epsilon Convergence threshold for \code{nmf.ffb}.
#' @param maxit Maximum number of iterations for \code{nmf.ffb}.
#' @param ... Additional arguments passed to \code{nmf.ffb} (except for
#'   \code{rank}, \code{seed}, \code{div}, \code{shuffle}, which are handled here).
#'   Also accepts: \code{nfolds} (number of folds, default 5; \code{div} also accepted),
#'   \code{seed} (master random seed, default \code{NULL}),
#'   \code{shuffle} (logical, default \code{TRUE}),
#'   \code{cores} (integer; number of parallel workers for the fold loop,
#'   default \code{getOption("mc.cores", 1L)}). Fold partitions and per-fold
#'   seeds are drawn before the loop and each \code{nmf.ffb} fit self-seeds,
#'   so results are identical to the sequential run for any \code{cores}.
#'
#' @return A numeric scalar: mean MAE across CV folds (the fits use
#'   \code{method = "mu"} unless \code{method} is given).  With
#'   \code{method = "fiml"}, the \code{\link{nmfkc.ecv}} object of the
#'   stage-1 rank sweep.
#'
#' @examples
#' Y <- t(iris[, -5])
#' Y1 <- Y[1:2, ]
#' Y2 <- Y[3:4, ]
#' mae <- nmf.ffb.cv(Y1, Y2, rank = 2, maxit = 500, nfolds = 3)
#' mae
#' \donttest{
#' # rank selection for the likelihood-based estimator
#' ecv <- nmf.ffb.cv(Y1, Y2, rank = 1:2, method = "fiml", nfolds = 3)
#' ecv$objfunc
#' }
#'
#' @seealso \code{\link{nmf.ffb}}
#' @export
nmf.ffb.cv <- function(
    Y1, Y2,
    rank = NULL,
    X.init = "nndsvd",
    X.L2.ortho = 100.0,
    C1.L1 = 1.0,        # L1 sparsity for C1 (Theta1)
    C2.L1 = 0.1,        # L1 sparsity for C2 (Theta2)
    epsilon = 1e-6,     # Convergence tolerance passed to nmf.ffb
    maxit = 5000,
    ...
){
  extra_cv <- base::list(...)
  ## method = "fiml": column-wise CV of the equilibrium mapping cannot select
  ## Theta1 (the reduced form M Y2 is the same with and without feedback), so
  ## the only tunable quantity is the stage-1 rank; delegate to the
  ## element-wise CV of the feed-forward fit.
  if (identical(extra_cv[["method", exact = TRUE]], "fiml")) {
    if (is.null(rank)) rank <- seq_len(min(3L, nrow(as.matrix(Y1))))
    ecv_args <- extra_cv
    ecv_args$method <- NULL
    ecv_args$C1.L1 <- NULL; ecv_args$C2.L1 <- NULL
    for (nm in c("X", "C1.restriction", "C1.restriction.threshold", "Phi.restriction",
                 "C1.L1.path", "select",
                 "fiml.maxit", "factr", "nmfkc.baseline", "M.simple"))
      ecv_args[[nm]] <- NULL
    if (is.null(ecv_args$X.init)) ecv_args$X.init <- X.init
    return(do.call("nmfkc.ecv", c(list(Y = as.matrix(Y1), A = as.matrix(Y2), rank = rank,
                                       X.L2.ortho = X.L2.ortho, epsilon = epsilon,
                                       maxit = maxit), ecv_args)))
  }
  nfolds  <- if (!is.null(extra_cv$nfolds))  extra_cv$nfolds  else if (!is.null(extra_cv$div)) extra_cv$div else 5
  ## CONVENTIONS.md 2: default seeds are concrete (123), so an analysis is
  ## reproducible without the user having to think about it.  This was the one
  ## CV in the package defaulting to NULL, i.e. the one whose fold assignment
  ## changed between runs.  Pass seed = NULL explicitly for the old behaviour.
  seed    <- if ("seed" %in% names(extra_cv)) extra_cv$seed else 123L
  ## Keep our own seeding out of the caller's random stream (no-op when
  ## seed = NULL, which here means "use the stream as it is").
  .rng <- .nmfkc.rng.save(seed)
  on.exit(.nmfkc.rng.restore(.rng), add = TRUE)
  shuffle <- if (!is.null(extra_cv$shuffle)) extra_cv$shuffle else TRUE
  cores   <- if (!is.null(extra_cv$cores))   extra_cv$cores   else getOption("mc.cores", 1L)
  div <- nfolds
  # ------------------------------------------------------------------
  # 1. Basic input checks
  #
  # NMF-FFB requires non-negative matrices. We also require that Y1 and Y2
  # share the same number of samples (columns) to allow paired CV splits.
  # ------------------------------------------------------------------
  if (!is.matrix(Y1)) Y1 <- as.matrix(Y1)
  if (!is.matrix(Y2)) Y2 <- as.matrix(Y2)
  if (any(!is.finite(Y1)) || any(!is.finite(Y2))) stop("Y1 and Y2 must not contain NA/NaN/Inf.")
  div <- as.integer(div)
  if (min(Y1) < 0 || min(Y2) < 0) stop("Y1 and Y2 must be non-negative.")
  if (ncol(Y1) != ncol(Y2)) {
    stop("Y1 and Y2 must have the same number of columns (samples).")
  }

  P1 <- nrow(Y1)
  P2 <- nrow(Y2)
  N  <- ncol(Y1)

  if (div < 2L) {
    stop("div (number of CV folds) must be >= 2.")
  }
  if (div > N) {
    stop("div (number of CV folds) must be <= number of samples.")
  }

  # ------------------------------------------------------------------
  # 2. Handle extra arguments for nmf.ffb
  #
  # We collect additional arguments in 'extra_args' but explicitly remove
  # those that are managed at the CV level:
  #   - div, shuffle : used only here, not passed to nmf.ffb.
  #   - rank        : passed explicitly from nmf.ffb.cv.
  #   - seed        : fold-specific seeds are generated here.
  # ------------------------------------------------------------------
  extra_args <- list(...)

  extra_args$div     <- NULL
  extra_args$nfolds  <- NULL
  extra_args$shuffle <- NULL
  extra_args$rank    <- NULL
  extra_args$seed    <- NULL
  extra_args$cores   <- NULL
  ## The column-wise scheme tunes C1.L1 / C2.L1, which only the
  ## multiplicative-update estimator uses, so the fold fits stay on
  ## method = "mu" unless the caller says otherwise (a "fiml" request was
  ## delegated to nmfkc.ecv above).  This keeps the CV scores identical to
  ## those before "fiml" became nmf.ffb()'s default.
  if (is.null(extra_args[["method", exact = TRUE]])) extra_args$method <- "mu"

  # ------------------------------------------------------------------
  # 3. Set RNG for CV partition and per-fold seeds
  #
  # If a master 'seed' is given:
  #   - it is used to define the CV partition (sample permutation),
  #   - independent seeds for each nmf.ffb run are drawn.
  # If 'seed' is NULL:
  #   - CV partition uses the current RNG state,
  #   - nmf.ffb runs use whatever the global RNG state is at call time.
  # ------------------------------------------------------------------
  if (!is.null(seed)) {
    set.seed(seed)
  }

  # Sample indices for fold division
  if (shuffle) {
    perm_index <- sample.int(N)
  } else {
    perm_index <- seq_len(N)
  }

  # Per-fold seeds for nmf.ffb (optional, only if master seed specified)
  if (!is.null(seed)) {
    seeds_fold <- sample.int(.Machine$integer.max, div)
  } else {
    seeds_fold <- rep(NA_integer_, div)
  }

  # ------------------------------------------------------------------
  # 4. Create CV folds
  #
  # We assign approximately N/div samples to each fold, distributing
  # any remainder one-by-one to the earliest folds.
  # 'block[i] = k' means sample i belongs to fold k (as test set).
  # ------------------------------------------------------------------
  remainder <- N %% div
  division  <- N %/% div
  block     <- integer(N)

  processed_count <- 0L
  for (i in 1:(div - 1L)) {
    plus       <- ifelse(i <= remainder, 1L, 0L)
    chunk_size <- division + plus
    idx_range  <- (processed_count + 1L):(processed_count + chunk_size)
    target_idx <- perm_index[idx_range]
    block[target_idx] <- i
    processed_count   <- processed_count + chunk_size
  }
  # Last fold gets all remaining samples
  target_idx <- perm_index[(processed_count + 1L):N]
  block[target_idx] <- div

  # Per-fold CV loss (MAE) will be stored here
  objfunc.block <- numeric(div)

  # ------------------------------------------------------------------
  # 5. Cross-validation loop
  #
  # For each fold j:
  #   - train on all samples not in fold j,
  #   - test on samples in fold j,
  #   - fit nmf.ffb on training data,
  #   - compute MAE on test block from equilibrium mapping M.model.
  # ------------------------------------------------------------------
  # Per-fold task. The partition 'block' and per-fold seeds 'seeds_fold' are
  # fixed above, and the only RNG consumer here is nmf.ffb() (self-seeds), so
  # folds are independent and deterministic; .nmfkc.parlapply returns results
  # in input (fold) order, so cores > 1 is bit-identical to the sequential run.
  fold_fun <- function(j) {
    # Train / Test split
    train_idx <- block != j
    test_idx  <- block == j

    Y1_train <- Y1[, train_idx, drop = FALSE]
    Y1_test  <- Y1[, test_idx,  drop = FALSE]
    Y2_train <- Y2[, train_idx, drop = FALSE]
    Y2_test  <- Y2[, test_idx,  drop = FALSE]

    # Fold-specific seed for nmf.ffb
    seed_j <- if (!is.null(seed)) seeds_fold[j] else NULL

    # Assemble arguments for nmf.ffb
    ffb_args <- c(
      extra_args,   # User-specified additional arguments (e.g., Q)
      list(
        Y1         = Y1_train,
        Y2         = Y2_train,
        rank       = rank,
        X.init     = X.init,
        X.L2.ortho = X.L2.ortho,
        C1.L1      = C1.L1,
        C2.L1      = C2.L1,
        epsilon    = epsilon,
        maxit      = maxit
      )
    )
    # Attach seed only when it is defined
    if (!is.null(seed_j)) {
      ffb_args$seed <- seed_j
    }

    # Call nmf.ffb on the training data (suppress messages for cleaner CV output)
    res_j <- suppressMessages(do.call("nmf.ffb", ffb_args))

    # If mapping is not usable, penalize this fold (do not crash CV)
    if (is.null(res_j$M.model) || any(!is.finite(res_j$M.model))) {
      return(Inf)
    }
    Pre_test <- res_j$M.model %*% Y2_test
    if (any(!is.finite(Pre_test))) {
      return(Inf)
    }
    mean(abs(Y1_test - Pre_test))
  }
  objfunc.block <- base::unlist(
    .nmfkc.parlapply(base::as.list(1:div), fold_fun,
                     cores = cores, envir = environment()),
    use.names = FALSE)

  # ------------------------------------------------------------------
  # 6. Aggregate CV score
  #
  # The overall CV criterion is the average MAE across all folds.
  # This is typically minimized over hyperparameter grids
  # (e.g., rank, X.L2.ortho, C1.L1, C2.L1) when tuning NMF-FFB.
  # ------------------------------------------------------------------
  objfunc <- mean(objfunc.block)
  return(objfunc)
}



#' @title Structural Diagnostics of a Fitted NMF-FFB Feedback Matrix
#'
#' @description
#' Two quantities that describe \emph{what} the selected feedback is, as opposed
#' to whether it is there (which is the job of the calibrated likelihood-ratio
#' test in \code{\link{nmf.ffb.inference}}).
#'
#' \strong{Cycle structure.} The equilibrium operator is \eqn{A = X\Theta_1} on
#' the indicators and \eqn{A_Q = \Theta_1X} on the factors, with the same
#' spectral radius \eqn{\rho}.  The strongly connected components of the
#' directed graph of \eqn{A_Q} are the cycles.  \eqn{\rho = 0} with a non-empty
#' support means \eqn{A} is nilpotent: the selected feedback is a cascade of
#' directed cross-factor paths without a closed loop.  Since \eqn{A \ge 0},
#' Perron--Frobenius gives a non-negative leading eigenvector, and its
#' normalized entries say how much of the loop each factor carries.
#'
#' \strong{Factor-level alignment.} Feedback of the form
#' \eqn{\Theta_1 = aX^\top\Psi^{-1}} enters each factor through the factor
#' scores of the factors and is observationally equivalent, under the Gaussian
#' working model, to a change of \eqn{\Theta_2} and of the factor covariance
#' \eqn{\Phi}; only the departure from that family is identified, through the
#' uniquenesses \eqn{\Psi}.  On the free set \eqn{F} of the exclusion restriction the
#' family is a subspace of dimension at most \eqn{Q(Q-1)}, and
#' \code{omega} is the share of the squared norm of \eqn{\Theta_1} that lies in
#' it.  \code{omega} has no natural zero: for an isotropic direction its
#' expectation is \code{omega0 = dim / n.free}, so it should be read against
#' \code{omega0} and against a simulation in which the true feedback is
#' indicator-level (there it averages about \code{1.3 * omega0}).
#'
#' @param object A fitted \code{nmf.ffb} object from
#'   \code{\link{nmf.ffb}} with \code{method = "fiml"}.
#' @param which Which feedback matrix to describe: \code{"selected"} (default,
#'   the BIC-selected support) or \code{"full"} (the unpenalized fit).
#' @return A list with \code{cycles} (\code{A.factor}, \code{rho},
#'   \code{cycle}, \code{n.cycles}, \code{perron}) and \code{omega}
#'   (\code{omega}, \code{omega0}, \code{dim}, \code{n.free}).
#' @seealso \code{\link{nmf.ffb}}, \code{\link{nmf.ffb.inference}}
#' @examples
#' \donttest{
#' set.seed(1)
#' Y2 <- matrix(runif(2 * 200), 2, 200)
#' X <- matrix(0.02, 6, 2); X[1:3, 1] <- 1; X[4:6, 2] <- 1
#' X <- sweep(X, 2, colSums(X), "/")
#' T1 <- matrix(0, 2, 6); T1[1, 5] <- 0.6; T1[2, 2] <- 0.5
#' L <- solve(diag(6) - X %*% T1)
#' Y1 <- pmax(L %*% (X %*% (matrix(c(1, 0.5, 0.3, 1), 2, 2) %*% Y2) +
#'                   matrix(rnorm(6 * 200, 0, 0.05), 6, 200)), 0)
#' fit <- nmf.ffb(Y1, Y2, Q = 2)
#' d <- nmf.ffb.diagnostics(fit)
#' d$cycles$rho          # spectral radius
#' d$cycles$cycle        # which factors lie on a cycle
#' c(d$omega$omega, d$omega$omega0)
#' }
#' @export
nmf.ffb.diagnostics <- function(object, which = c("selected", "full")) {
  which <- base::match.arg(which)
  if (!base::inherits(object, "nmf.ffb"))
    base::stop("nmf.ffb.diagnostics() expects an object from nmf.ffb().")
  if (base::is.null(object$C1.free) || base::is.null(object$psi))
    base::stop("this fit carries no exclusion restriction / uniquenesses: nmf.ffb.diagnostics() needs method = \"fiml\".")
  T1 <- if (which == "selected") object$C1 else object$full$C1
  psi <- if (which == "selected") object$psi else object$full$psi
  if (base::is.null(T1)) base::stop("no ", which, " feedback matrix on this object.")
  ## The best few distinct supports, so that the report says how thin the margin is.  BIC selects one
  ## support; adding a free entry costs log(N), and a difference in BIC is twice the log of a Bayes factor,
  ## so a difference below about 2 is not evidence for one support over the other (Kass and Raftery 1995).
  ## When the best supports form a chain under inclusion their intersection is the part the criterion does
  ## not put in doubt; when they do not, no single support should be read off.
  top <- NULL; core <- NULL; envelope <- NULL; nested <- NA
  if (!base::is.null(object$candidates) && !base::is.null(object$supports)) {
    cd <- object$candidates[base::order(object$candidates$BIC, object$candidates$nnz), , drop = FALSE]
    k <- base::min(3L, base::nrow(cd))
    top <- base::data.frame(order = base::seq_len(k), nnz = cd$nnz[base::seq_len(k)],
                            rho = cd$rho[base::seq_len(k)], BIC = cd$BIC[base::seq_len(k)],
                            dBIC = cd$BIC[base::seq_len(k)] - cd$BIC[1],
                            stringsAsFactors = FALSE)
    tops <- base::lapply(base::seq_len(k), function(i) object$supports[[cd$support_id[i]]])
    core <- base::Reduce(`&`, tops) * 1
    envelope <- base::Reduce(`|`, tops) * 1
    nested <- base::all(base::outer(base::seq_len(k), base::seq_len(k), base::Vectorize(function(a, b)
      base::all(!tops[[a]] | tops[[b]]) || base::all(!tops[[b]] | tops[[a]]))))
  }
  base::list(cycles = .ffb.fiml.cycles(object$X, T1),
             omega = .ffb.fiml.omega(object$X, T1, psi, object$C1.free),
             top = top, core = core, envelope = envelope, nested = nested,
             log.N = if (!base::is.null(object$N)) base::log(object$N) else NA_real_,
             which = which)
}

#' @title Heuristic Variable Splitting for NMF-FFB
#'
#' @description
#' Infers a heuristic partition of observed variables into exogenous (\eqn{Y_2})
#' and endogenous (\eqn{Y_1}) blocks for use in NMF-FFB.
#' The method is based on positive-SEM logic, causal ordering, and optional
#' sign alignment using the first principal component (PC1).
#'
#' The procedure:
#' \itemize{
#'   \item internally standardizes variables (mean 0, sd 1),
#'   \item optionally flips signs so that most variables align positively with PC1,
#'   \item infers a causal ordering by repeatedly regressing each variable on the
#'         remaining ones and selecting the variable with the largest minimum
#'         standardized coefficient,
#'   \item determines an exogenous block by scanning the ordering from upstream
#'         and stopping at the first variable whose strongest parent coefficient
#'         exceeds \code{threshold}.
#' }
#'
#' If \code{n.exogenous} is supplied, it overrides the automatic threshold rule.
#'
#' @param x A numeric matrix or data frame with
#'   \strong{rows = samples} and \strong{columns = observed variables}.
#' @param n.exogenous Optional integer specifying the number of exogenous variables
#'   (\eqn{Y_2}). If \code{NULL}, the number is inferred automatically by the
#'   coefficient cut-off rule.
#' @param threshold Standardized regression-coefficient threshold used in the
#'   automatic exogenous–endogenous split. A variable is treated as endogenous
#'   once its maximum standardized parent coefficient exceeds this value.
#'   (Default: \code{0.1})
#' @param auto.flipped Logical; if \code{TRUE}, applies PC1-based automatic
#'   sign flipping after standardization to ensure consistent orientation.
#'   (Default: \code{TRUE})
#' @param verbose Logical; if \code{TRUE}, prints progress messages and the
#'   resulting variable split. (Default: \code{FALSE})
#' @param ... Reserved for future use; currently unused (also accepted
#'   by the \code{\link{nmf.ffb.split}} alias for argument forwarding).
#'
#' @return A list with:
#'   \item{endogenous.variables}{
#'     Character vector of variables selected as endogenous (\eqn{Y_1}).}
#'   \item{exogenous.variables}{
#'     Character vector of variables selected as exogenous (\eqn{Y_2}).}
#'   \item{ordered.variables}{
#'     Variables in inferred causal order (from exogenous to endogenous).}
#'   \item{is.flipped}{
#'     Logical vector indicating which variables were sign-flipped during processing.}
#'   \item{n.exogenous}{
#'     Integer giving the number of exogenous variables.}
#'
#' @examples
#' # Infer exogenous/endogenous split from iris
#' sp <- nmf.ffb.split(iris[, -5], n.exogenous = 2)
#' sp$endogenous.variables
#' sp$exogenous.variables
#'
#' @seealso \code{\link{nmf.ffb}}
#' @export
nmf.ffb.split <- function(x, n.exogenous = NULL, threshold = 0.1,
                          auto.flipped = TRUE, verbose = FALSE, ...) {

  if (!is.matrix(x) && !is.data.frame(x))
    stop("x must be a numeric matrix or data frame.")

  X_raw <- as.matrix(x)
  P <- ncol(X_raw)
  col_names <- colnames(X_raw)
  if (is.null(col_names)) {
    col_names <- paste0("V", 1:P)
    colnames(X_raw) <- col_names
  }

  # --------------------------------------------------------------------
  # Preprocessing Step 1: Standardize all variables
  #
  # Variables are centered and scaled (mean 0, sd 1). NMF-FFB requires
  # non-negative matrices, but the purpose of this function is only to
  # infer variable roles (Y1/Y2), so standardized values are allowed.
  #
  # Missing or NaN values resulting from constant columns are set to 0.
  # --------------------------------------------------------------------
  X_calc <- scale(X_raw, center = TRUE, scale = TRUE)
  X_calc[is.na(X_calc)] <- 0
  X_calc[is.nan(X_calc)] <- 0

  all_indices <- 1:P

  # --------------------------------------------------------------------
  # Preprocessing Step 2: Optional sign flipping based on PC1 alignment
  #
  # In positive SEM (and NMF-FFB), variables should ideally have
  # consistent sign orientation. To enforce this heuristic, variables
  # negatively correlated with the first principal component are flipped.
  #
  # This stabilizes the causal-ordering heuristic by avoiding mixtures
  # of arbitrary sign conventions in the raw data.
  # --------------------------------------------------------------------
  is.flipped <- rep(FALSE, P)
  names(is.flipped) <- col_names

  if (auto.flipped) {
    if (verbose) message("Step 0: Checking correlations with PC1 (on standardized data)...")

    svd_res <- svd(X_calc)
    pc1 <- svd_res$u[, 1]

    cors <- stats::cor(X_calc, pc1)
    # Ensure majority alignment with PC1
    if (stats::median(cors, na.rm = TRUE) < 0) cors <- -cors

    flip_idx <- which(cors < 0)

    if (length(flip_idx) > 0) {
      is.flipped[flip_idx] <- TRUE
      X_calc[, flip_idx] <- -X_calc[, flip_idx]

      if (verbose) {
        message(sprintf("   -> Detected %d flipped variables: %s",
                    length(flip_idx), paste(col_names[flip_idx], collapse=", ")))
      }
    }
  }

  # --------------------------------------------------------------------
  # Step 1: Causal ordering heuristic
  #
  # We infer an ordering of variables consistent with positive-SEM logic:
  # repeatedly select the variable that has the *largest minimum* coefficient
  # when regressed on the remaining variables. This favors variables that
  # are least explained by others → likely exogenous.
  #
  # The resulting order approximates a causal topological order in which
  # exogenous variables appear early and endogenous variables later.
  # --------------------------------------------------------------------
  if (verbose) message("Step 1: Inferring Causal Ordering...")

  active_set <- all_indices
  ordering_reversed <- integer(P)

  for (t in 1:(P - 1)) {
    scores <- numeric(length(active_set))

    for (i in seq_along(active_set)) {
      target_col <- active_set[i]
      pred_cols <- active_set[-i]

      y_vec <- X_calc[, target_col]
      X_mat <- X_calc[, pred_cols, drop = FALSE]

      coefs <- tryCatch({
        stats::coef(stats::lm(y_vec ~ X_mat - 1))
      }, error = function(e) rep(NA_real_, length(pred_cols)))

      # Positive SEM → keep the smallest coefficient (weakest positive predictor)
      if (all(is.na(coefs))) {
        scores[i] <- -Inf
      } else {
        scores[i] <- min(coefs, na.rm = TRUE)
      }
    }

    best_idx <- which.max(scores)
    ordering_reversed[t] <- active_set[best_idx]
    active_set <- active_set[-best_idx]
  }
  ordering_reversed[P] <- active_set[1]

  # Causal order: exogenous → endogenous
  ordering_indices <- rev(ordering_reversed)

  # --------------------------------------------------------------------
  # Step 2: Automatic identification of exogenous variables
  #
  # Sweep through the causal ordering. For each variable, regress it on all
  # earlier variables. If its strongest parent coefficient exceeds the
  # threshold, the variable is considered endogenous.
  #
  # Variables before this point → exogenous (Y2)
  # Variables after this point → endogenous (Y1)
  #
  # If n.exogenous is given, it overrides this automatic rule.
  # --------------------------------------------------------------------
  if (is.null(n.exogenous)) {
    if (verbose) message("Step 2: Detecting optimal cut-off for exogenous variables...")
    cutoff <- 1

    for (k in seq_len(max(P - 2, 0)) + 1) {
      curr_idx <- ordering_indices[k]
      parent_indices <- ordering_indices[seq_len(k - 1)]

      y_vec <- X_calc[, curr_idx]
      if (length(parent_indices) == 0) next
      X_parents <- X_calc[, parent_indices, drop = FALSE]

      coefs <- stats::coef(stats::lm(y_vec ~ X_parents - 1))
      coefs <- coefs[is.finite(coefs)]
      max_influence <- if (length(coefs) > 0) max(coefs) else 0

      if (max_influence > threshold) {
        if (verbose)
          message(sprintf("   [%d] %s : Max std.coef=%.3f -> Endogenous (Stop)",
                      k, col_names[curr_idx], max_influence))
        break
      } else {
        cutoff <- k
        if (verbose)
          message(sprintf("   [%d] %s : Max std.coef=%.3f -> Exogenous (Continue)",
                      k, col_names[curr_idx], max_influence))
      }
    }
    n.exogenous <- cutoff
  }

  # --------------------------------------------------------------------
  # Step 3: Final classification into Y1 and Y2
  #
  # Variables appearing early in the ordering (determined by cut-off) are
  # treated as exogenous (Y2). The remainder are endogenous (Y1).
  #
  # Ordered list shows the full inferred causal sequence.
  # --------------------------------------------------------------------
  if(n.exogenous < 1 || n.exogenous >= P)
    stop("n.exogenous must be between 1 and P-1.")
  idx_exo <- ordering_indices[1:n.exogenous]
  idx_endo <- ordering_indices[(n.exogenous + 1):P]

  exogenous.variables <- col_names[idx_exo]
  endogenous.variables <- col_names[idx_endo]
  ordered.variables <- col_names[ordering_indices]

  if (verbose) {
    message("\n--- Auto Split Result ---")
    message(sprintf("Exogenous (Y2, n=%d): %s",
                n.exogenous, paste(exogenous.variables, collapse=", ")))
    message(sprintf("Endogenous (Y1, n=%d): %s ...",
                length(endogenous.variables),
                paste(utils::head(endogenous.variables, 3), collapse=", ")))
  }

  return(list(
    endogenous.variables = endogenous.variables,
    exogenous.variables = exogenous.variables,
    ordered.variables = ordered.variables,
    is.flipped = is.flipped,
    n.exogenous = as.integer(n.exogenous)
  ))
}




############################################################
## Common DOT Helpers
############################################################

############################################################
## 1. nmf.ffb.DOT  (for NMF-FFB visualization)
############################################################

#' Generate a Graphviz DOT Diagram for an NMF-FFB Model
#'
#' @description
#' Creates a Graphviz DOT script that visualizes the structural network
#' estimated by \code{nmf.ffb}.
#' The resulting diagram displays:
#' \itemize{
#'   \item endogenous observed variables (\eqn{Y_1}),
#'   \item exogenous observed variables (\eqn{Y_2}),
#'   \item latent factors (\eqn{F_1}, \dots, \eqn{F_Q}),
#' }
#' together with the non-negative path coefficients whose magnitudes
#' exceed a user-specified threshold.
#'
#' Directed edges represent estimated relationships:
#' \itemize{
#'   \item \eqn{Y_2 \rightarrow F_q}: entries of \code{C2} (exogenous loadings),
#'   \item \eqn{F_q \rightarrow Y_1}: rows of \code{X} (factor-to-endogenous mappings),
#'   \item \eqn{Y_1 \rightarrow F_q}: entries of \code{C1} (feedback paths).
#' }
#'
#' Edge widths are scaled by coefficient magnitude, and nodes are placed
#' in optional visual clusters. Only variables participating in
#' edges above the threshold are displayed, while latent factors are always shown.
#'
#' @param result A list returned by \code{nmf.ffb}, containing matrices
#'   \code{X}, \code{C1}, and \code{C2}.
#' @param weight_scale Base scaling factor for edge widths.
#' @param weight_scale_c2 Scaling factor for edges
#'   \eqn{Y_2 \rightarrow F_q} (C2 matrix). Defaults to \code{weight_scale}.
#' @param weight_scale_x1 Scaling factor for edges
#'   \eqn{F_q \rightarrow Y_1} (X matrix). Defaults to \code{weight_scale}.
#' @param weight_scale_feedback Scaling factor for feedback edges
#'   \eqn{Y_1 \rightarrow F_q} (C1 matrix). Defaults to \code{weight_scale}.
#' @param threshold Minimum coefficient value needed for an edge to be drawn.
#' @param sig.level Significance level for filtering edges by p-value
#'   (requires inference results). Edges with p-value above this level are omitted.
#' @param rankdir Graphviz rank direction (e.g., \code{"LR"}, \code{"TB"}).
#' @param fill Logical; whether to use filled node shapes.
#' @param ... For backward compatibility: accepts deprecated names
#'   \code{weight_scale_y2f} (use \code{weight_scale_c2}) and
#'   \code{weight_scale_fy1} (use \code{weight_scale_x1}).
#' @param cluster.box Character string controlling the visibility and style
#'   of cluster frames around Y2, factors, and Y1 blocks.
#'   One of \code{"normal"}, \code{"faint"}, \code{"invisible"}, \code{"none"}.
#' @param cluster.labels Optional character vector of length 3 giving custom
#'   labels for the Y2, factor, and Y1 clusters.
#' @param hide.isolated Logical. If \code{TRUE} (default), Y1 and Y2 nodes
#'   that have no edges at or above \code{threshold} are excluded from the graph.
#' @param model Which fit of a \code{method = "fiml"} object to draw:
#'   \code{"selected"} (default; the BIC-selected model, with significance
#'   stars when inference results are present), \code{"null"} (the
#'   feed-forward null: \eqn{\Theta_1 = 0}, \eqn{\Theta_2} from
#'   \code{result$null}) or \code{"full"} (the unpenalized feedback fit from
#'   \code{result$full}).  The latter two ignore \code{result$coefficients},
#'   which refer to the selected model.  Ignored for \code{method = "mu"}
#'   objects, which carry a single fit.
#' @param sig.level Significance level for filtering structural edges
#'   (\eqn{C_1} feedback and \eqn{C_2} exogenous loadings) when
#'   inference results are present.  If \code{result} contains a
#'   \code{coefficients} data frame from \code{\link{nmf.ffb.inference}},
#'   only edges with \code{prob.unsupported < sig.level} are drawn, with
#'   significance stars (\code{*} \code{**} \code{***}) appended to
#'   the edge label.  The \eqn{X} (factor-to-\eqn{Y_1}) edges are
#'   never starred since the basis is not the inference target.
#'   Set to \code{NULL} to disable significance filtering and fall
#'   back to the \code{threshold} magnitude filter for both \eqn{C_1}
#'   and \eqn{C_2}.  Default is \code{0.1}.
#'
#' @return A character string representing a valid Graphviz DOT script.
#'
#' @examples
#' Y <- t(iris[, -5])
#' Y1 <- Y[1:2, ]
#' Y2 <- Y[3:4, ]
#' result <- nmf.ffb(Y1, Y2, rank = 2, maxit = 500)
#' dot <- nmf.ffb.DOT(result)
#' cat(dot)
#'
#' @seealso \code{\link{nmf.ffb}}, \code{\link{nmf.ffb.inference}},
#'   \code{\link{plot.nmfkc.DOT}}
#' @export
nmf.ffb.DOT <- function(result,
                        weight_scale          = 5,
                        weight_scale_c2       = weight_scale,
                        weight_scale_x1       = weight_scale,
                        weight_scale_feedback = weight_scale,
                        threshold             = 0.01,
                        sig.level             = 0.1,
                        rankdir               = "LR",
                        fill                  = TRUE,
                        cluster.box           = c("normal", "faint", "invisible", "none"),
                        cluster.labels        = NULL,
                        hide.isolated         = TRUE,
                        model                 = c("selected", "null", "full"),
                        ...) {

  ## Backward compatibility: accept deprecated names via ...
  extra_args <- base::list(...)
  if (!base::is.null(extra_args$weight_scale_y2f)) weight_scale_c2 <- extra_args$weight_scale_y2f
  if (!base::is.null(extra_args$weight_scale_fy1)) weight_scale_x1 <- extra_args$weight_scale_fy1

  ## Which fit of a fiml object to draw.  The null and the unpenalized fit
  ## are substituted into C1 / C2; the inference table (selected model) is
  ## dropped so that no stars from another model decorate their edges.
  model <- match.arg(model)
  if (model != "selected") {
    alt <- result[[model]]
    if (is.null(alt))
      stop(sprintf("result has no `%s` component; model = \"%s\" needs an nmf.ffb(method = \"fiml\") fit.",
                   model, model))
    result$C1 <- if (model == "null") 0 * result$C1 else alt$C1
    result$C2 <- alt$C2
    result$coefficients <- NULL
  }

  ## ---------------------------------------------------------------
  ## Cluster style selection
  ## ---------------------------------------------------------------
  cluster.box <- match.arg(cluster.box)

  cluster_style <- switch(cluster.box,
                          normal    = "rounded",
                          faint     = "rounded,dashed",
                          invisible = "rounded",
                          none      = "none")

  cluster_color <- switch(cluster.box,
                          normal    = "black",
                          faint     = "gray80",
                          invisible = "none",
                          none      = "none")

  cluster_penwidth <- switch(cluster.box,
                             normal    = 1.0,
                             faint     = 0.7,
                             invisible = 0.0,
                             none      = 0.0)

  ## ---------------------------------------------------------------
  ## Cluster titles
  ## ---------------------------------------------------------------
  default.labels <- c("Exogenous (Y2)", "Latent Factors", "Endogenous (Y1)")
  if (is.null(cluster.labels)) {
    titles <- default.labels
  } else {
    if (!is.character(cluster.labels) || length(cluster.labels) != 3L) {
      stop("cluster.labels must be a character vector of length 3: c(label_Y2, label_F, label_Y1).")
    }
    titles <- cluster.labels
  }

  ## ---------------------------------------------------------------
  ## Extract matrices and labels
  ## ---------------------------------------------------------------
  X  <- result$X
  C1 <- result$C1
  C2 <- result$C2

  if (is.null(X) || is.null(C1) || is.null(C2)) {
    stop("result must contain elements X, C1, and C2.")
  }

  Y1_labels <- rownames(X)
  Y2_labels <- colnames(C2)

  if (is.null(Y1_labels)) Y1_labels <- paste0("Y1_", seq_len(nrow(X)))
  if (is.null(Y2_labels)) Y2_labels <- paste0("Y2_", seq_len(ncol(C2)))

  P1 <- length(Y1_labels)
  P2 <- length(Y2_labels)
  Q  <- ncol(X)

  ## ---------------------------------------------------------------
  ## Identify nodes involved in edges >= threshold
  ## ---------------------------------------------------------------
  if (isTRUE(hide.isolated)) {
    used_y2 <- apply(C2, 2L, function(col) any(col >= threshold, na.rm = TRUE))
    used_y1_from_X  <- apply(X,  1L, function(row) any(row >= threshold, na.rm = TRUE))
    used_y1_from_C1 <- apply(C1, 2L, function(col) any(col >= threshold, na.rm = TRUE))
    used_y1 <- used_y1_from_X | used_y1_from_C1
    idx_y1 <- which(used_y1)
    idx_y2 <- which(used_y2)
  } else {
    idx_y1 <- seq_len(P1)
    idx_y2 <- seq_len(P2)
  }

  ## ---------------------------------------------------------------
  ## Assign internal DOT node IDs
  ## ---------------------------------------------------------------
  Y1_ids <- .nmfkc_dot_sanitize_id(paste0("Y1_", seq_len(P1)))
  Y2_ids <- .nmfkc_dot_sanitize_id(paste0("Y2_", seq_len(P2)))
  F_ids  <- .nmfkc_dot_sanitize_id(paste0("F_",  seq_len(Q)))

  ## Node colors
  COLOR_Y2_NODE     <- "lightcoral"
  COLOR_Y1_NODE     <- "lightblue"
  COLOR_FACTOR_NODE <- "wheat"

  ## ---------------------------------------------------------------
  ## Header
  ## ---------------------------------------------------------------
  dot_script <- .nmfkc_dot_header(
    graph_name = "NMF_SEM_Full_Mechanism",
    rankdir    = rankdir
  )

  ## ---------------------------------------------------------------
  ## Y2 cluster
  ## ---------------------------------------------------------------
  dot_script <- paste0(
    dot_script,
    "\n  // Exogenous variables (Y2)\n",
    .nmfkc_dot_cluster_nodes(
      cluster_id        = "Y2",
      title             = titles[1],
      node_ids          = Y2_ids[idx_y2],
      node_labels       = Y2_labels[idx_y2],
      shape             = "box",
      fill              = fill,
      fillcolor         = COLOR_Y2_NODE,
      line_width        = 1.5,
      cluster_style     = cluster_style,
      cluster_color     = cluster_color,
      cluster_penwidth  = cluster_penwidth
    )
  )

  ## ---------------------------------------------------------------
  ## Y1 cluster
  ## ---------------------------------------------------------------
  dot_script <- paste0(
    dot_script,
    "\n  // Endogenous variables (Y1)\n",
    .nmfkc_dot_cluster_nodes(
      cluster_id        = "Y1",
      title             = titles[3],
      node_ids          = Y1_ids[idx_y1],
      node_labels       = Y1_labels[idx_y1],
      shape             = "box",
      fill              = fill,
      fillcolor         = COLOR_Y1_NODE,
      line_width        = 1.5,
      cluster_style     = cluster_style,
      cluster_color     = cluster_color,
      cluster_penwidth  = cluster_penwidth
    )
  )

  ## ---------------------------------------------------------------
  ## Latent factor cluster
  ## ---------------------------------------------------------------
  dot_script <- paste0(
    dot_script,
    "\n  // Latent Factors (F)\n",
    .nmfkc_dot_cluster_nodes(
      cluster_id        = "F",
      title             = titles[2],
      node_ids          = F_ids,
      node_labels       = paste0("Factor ", seq_len(Q)),
      shape             = "ellipse",
      fill              = fill,
      fillcolor         = COLOR_FACTOR_NODE,
      line_width        = 1.0,
      cluster_style     = cluster_style,
      cluster_color     = cluster_color,
      cluster_penwidth  = cluster_penwidth
    )
  )

  ## ---------------------------------------------------------------
  ## Edge defaults
  ## ---------------------------------------------------------------
  dot_script <- paste0(
    dot_script,
    '\n  edge [fontname="Arial", fontsize=8, arrowhead=open];\n'
  )

  pw     <- .nmfkc_dot_penwidth
  digits <- .nmfkc_dot_digits_from_threshold(threshold)
  fmtc   <- function(x) .nmfkc_dot_format_coef(x, digits)

  ## ---------------------------------------------------------------
  ## Significance stars for C1 (feedback) and C2 (exogenous) edges,
  ## both from nmf.ffb.inference().  X (F -> Y1) edges are NOT
  ## starred even when inference results are present, since the
  ## basis is not the inference target.
  ##
  ## Filters by the optional `Type` column ("C1" / "C2") if present
  ## (newer inference output); falls back to row-name matching for
  ## back-compatibility with pre-v0.6.8 inference output that only
  ## carried C2.
  ## ---------------------------------------------------------------
  pval_to_stars <- function(p) {
    if (!is.finite(p)) ""
    else if (p < 0.001) "***"
    else if (p < 0.01)  "**"
    else if (p < 0.05)  "*"
    else                ""
  }

  C1_stars <- NULL; C1_show <- NULL
  C2_stars <- NULL; C2_show <- NULL
  if (!is.null(result$coefficients)) {
    cf        <- result$coefficients
    fac_names <- rownames(C2)
    y1_names  <- colnames(C1)
    y2_names  <- colnames(C2)

    has_type <- !is.null(cf$Type)
    cf_C1 <- if (has_type) cf[cf$Type == "C1", , drop = FALSE] else NULL
    cf_C2 <- if (has_type) cf[cf$Type == "C2", , drop = FALSE] else cf

    ## ---- C2 stars (Y2 -> F) ----
    C2_stars <- matrix("", nrow = Q, ncol = P2)
    C2_pval  <- matrix(NA_real_, nrow = Q, ncol = P2)
    for (k in seq_len(nrow(cf_C2))) {
      q  <- match(cf_C2$Basis[k], fac_names)
      p2 <- match(cf_C2$Covariate[k], y2_names)
      if (!is.na(q) && !is.na(p2) && !is.na(cf_C2$prob.unsupported[k])) {
        C2_pval[q, p2]  <- cf_C2$prob.unsupported[k]
        C2_stars[q, p2] <- pval_to_stars(cf_C2$prob.unsupported[k])
      }
    }
    if (!is.null(sig.level)) {
      C2_show <- !is.na(C2_pval) & C2_pval < sig.level
    }

    ## ---- C1 stars (Y1 -> F, feedback) ----
    if (!is.null(cf_C1) && nrow(cf_C1) > 0L) {
      C1_stars <- matrix("", nrow = Q, ncol = P1)
      C1_pval  <- matrix(NA_real_, nrow = Q, ncol = P1)
      for (k in seq_len(nrow(cf_C1))) {
        q  <- match(cf_C1$Basis[k], fac_names)
        p1 <- match(cf_C1$Covariate[k], y1_names)
        if (!is.na(q) && !is.na(p1) && !is.na(cf_C1$prob.unsupported[k])) {
          C1_pval[q, p1]  <- cf_C1$prob.unsupported[k]
          C1_stars[q, p1] <- pval_to_stars(cf_C1$prob.unsupported[k])
        }
      }
      if (!is.null(sig.level)) {
        C1_show <- !is.na(C1_pval) & C1_pval < sig.level
      }
    }
  }

  ## ---------------------------------------------------------------
  ## 1. Y2 → F edges (C2)
  ## ---------------------------------------------------------------
  dot_script <- paste0(
    dot_script,
    '\n  // 1. External Driving (Y2 -> Factor) [C2]\n',
    '  edge [color=black, fontcolor=black, style=solid];\n'
  )

  max_C2 <- suppressWarnings(max(C2, na.rm = TRUE))
  if (is.finite(max_C2) && max_C2 > 0) {
    for (q in seq_len(Q)) {
      for (p2 in idx_y2) {
        weight <- C2[q, p2]
        show <- if (!is.null(C2_show)) C2_show[q, p2]
                else is.finite(weight) && weight >= threshold
        if (show) {
          pen <- pw(weight, max_C2, weight_scale_c2)
          lab <- fmtc(weight)
          if (!is.null(C2_stars)) lab <- paste0(lab, C2_stars[q, p2])
          path <- sprintf('  %s -> %s [label="%s", penwidth=%.2f];\n',
                          Y2_ids[p2], F_ids[q], lab, pen)
          dot_script <- paste0(dot_script, path)
        }
      }
    }
  }

  ## ---------------------------------------------------------------
  ## 2. F → Y1 edges (X)
  ## ---------------------------------------------------------------
  dot_script <- paste0(
    dot_script,
    '\n  // 2. Generation (Factor -> Y1) [X]\n',
    '  edge [color="gray0", fontcolor="gray0", style=solid];\n'
  )

  max_X <- suppressWarnings(max(X, na.rm = TRUE))
  if (is.finite(max_X) && max_X > 0) {
    for (q in seq_len(Q)) {
      for (p1 in idx_y1) {
        weight <- X[p1, q]
        if (is.finite(weight) && weight >= threshold) {
          pen <- pw(weight, max_X, weight_scale_x1)
          lab <- fmtc(weight)
          path <- sprintf('  %s -> %s [label="%s", penwidth=%.2f];\n',
                          F_ids[q], Y1_ids[p1], lab, pen)
          dot_script <- paste0(dot_script, path)
        }
      }
    }
  }

  ## ---------------------------------------------------------------
  ## 3. Y1 → F edges (C1 feedback)
  ## ---------------------------------------------------------------
  dot_script <- paste0(
    dot_script,
    '\n  // 3. Internal Feedback (Y1 -> Factor) [C1]\n',
    '  edge [style=dashed, color="gray0", fontcolor="gray0"];\n'
  )

  max_C1 <- suppressWarnings(max(C1, na.rm = TRUE))
  if (is.finite(max_C1) && max_C1 > 0) {
    for (q in seq_len(Q)) {
      for (p1 in idx_y1) {
        weight <- C1[q, p1]
        ## When inference results provide C1 p-values, prefer the
        ## significance-based filter (parallels the C2 branch above);
        ## otherwise fall back to the magnitude threshold.
        show <- if (!is.null(C1_show)) C1_show[q, p1]
                else is.finite(weight) && weight >= threshold
        if (show) {
          pen <- pw(weight, max_C1, weight_scale_feedback)
          lab <- fmtc(weight)
          if (!is.null(C1_stars)) lab <- paste0(lab, C1_stars[q, p1])
          path <- sprintf('  %s -> %s [label="%s", penwidth=%.2f];\n',
                          Y1_ids[p1], F_ids[q], lab, pen)
          dot_script <- paste0(dot_script, path)
        }
      }
    }
  }

  result <- paste0(dot_script, "}\n")
  class(result) <- c("nmf.ffb.DOT", "nmfkc.DOT")
  result
}





############################################################
## 2. nmfkc.DOT  (Static NMF / NMF-with-covariates visualization)
############################################################

#' Generate Graphviz DOT Scripts for NMF or NMF-with-Covariates Models
#'
#' @description
#' Produces a Graphviz DOT script visualizing the structure of an NMF model
#' (\eqn{Y \approx X C A}) or its simplified forms.
#'
#' Supported visualization types:
#' \itemize{
#'   \item \code{"YX"} — Standard NMF view: latent factors \eqn{X} map to observations \eqn{Y}.
#'   \item \code{"YA"} — Direct regression view: covariates \eqn{A} map directly to \eqn{Y}
#'         using the combined coefficient matrix \eqn{X C}.
#'   \item \code{"YXA"} — Full tri-factorization: \eqn{A \rightarrow C \rightarrow X \rightarrow Y}.
#' }
#'
#' Edge widths are scaled by coefficient magnitude, and nodes with no edges
#' above the threshold are omitted from the visualization.
#'
#' @param result The return value from \code{nmfkc}, containing matrices
#'   \code{X}, \code{B}, and optionally \code{C}.
#' @param type Character string specifying the visualization style:
#'   one of \code{"YX"}, \code{"YA"}, \code{"YXA"}.
#' @param threshold Minimum coefficient magnitude to display an edge. When
#'   \code{C.signed = TRUE} (signed \eqn{\Theta}) this is an absolute-value cut,
#'   i.e. an edge is drawn when \eqn{|coef| \ge} \code{threshold}.
#' @param C.signed Logical or \code{NULL}. Whether the coefficient matrix
#'   \eqn{\Theta} (\code{= C}) may be signed (real-valued). When \code{TRUE},
#'   the \code{threshold} is applied to \eqn{|coef|}, edge widths are scaled by
#'   \eqn{|coef|}, and negative edges are drawn as black \strong{dashed} lines
#'   (positive edges remain solid) to distinguish them; the numeric edge labels
#'   keep their sign. When \code{FALSE}, the historical non-negative behaviour is used
#'   (negative coefficients fall below the threshold and are hidden). When
#'   \code{NULL} (default), the mode is auto-detected from the fit
#'   (\code{result$C.signed}) or from the presence of negative entries in
#'   \eqn{C} / \eqn{XC}. The basis \eqn{X} is always non-negative, so
#'   \eqn{X \rightarrow Y} edges are unaffected.
#' @param sig.level Significance level for filtering C edges when inference
#'   results are available (i.e., \code{x} is of class \code{"nmfkc.inference"}).
#'   Only edges with p-value below \code{sig.level} are shown, decorated with
#'   significance stars (\code{*}, \code{**}, \code{***}). Set to \code{NULL}
#'   to disable filtering and show all edges above \code{threshold}. Default is 0.1.
#' @param rankdir Graphviz rank direction (e.g., \code{"LR"}, \code{"TB"}).
#' @param fill Logical; whether nodes should be drawn with filled shapes.
#' @param weight_scale Base scaling factor for edge widths.
#' @param weight_scale_ax Scaling factor for edges \eqn{A \rightarrow X} (type \code{"YXA"}).
#' @param weight_scale_xy Scaling factor for edges \eqn{X \rightarrow Y}.
#' @param weight_scale_ay Scaling factor for edges \eqn{A \rightarrow Y} (type \code{"YA"}).
#' @param Y.label Optional character vector for labels of Y nodes.
#' @param X.label Optional character vector for labels of X (latent factor) nodes.
#' @param A.label Optional character vector for labels of A (covariate) nodes.
#' @param Y.title Cluster title for Y nodes.
#' @param X.title Cluster title for X nodes.
#' @param A.title Cluster title for A nodes.
#' @param hide.isolated Logical. If \code{TRUE} (default), Y and A nodes that have no
#'   edges at or above \code{threshold} are excluded from the graph.
#'
#' @return A character string representing a Graphviz DOT script.
#'
#' @seealso \code{\link{nmfkc}}, \code{\link{nmf.rrr.DOT}}, \code{\link{nmf.ffb.DOT}},
#'   \code{\link{nmfkc.ar.DOT}}, \code{\link{plot.nmfkc.DOT}}
#' @examples
#' Y <- matrix(cars$dist, nrow = 1)
#' A <- rbind(1, cars$speed)
#' result <- nmfkc(Y, A, rank = 1)
#' dot <- nmfkc.DOT(result)
#' cat(dot)
#'
#' @export
nmfkc.DOT <- function(
    result,
    type = c("YX","YA","YXA"),
    threshold = 0.01,
    C.signed = NULL,
    sig.level = 0.1,
    rankdir   = "LR",
    fill      = TRUE,
    weight_scale    = 5,
    weight_scale_ax = weight_scale,
    weight_scale_xy = weight_scale,
    weight_scale_ay = weight_scale,
    Y.label = NULL, X.label = NULL, A.label = NULL,
    Y.title = "Observation (Y)",
    X.title = "Basis (X)",
    A.title = "Covariates (A)",
    hide.isolated = TRUE
) {

  type <- match.arg(type)

  ## ---------------------------------------------------------
  ## Required matrices
  ## ---------------------------------------------------------
  X <- result$X
  B <- result$B
  if (is.null(X) || is.null(B)) {
    stop("result must contain X and B.")
  }

  ## If C exists and is a proper NMF-with-covariates factor:
  hasA <- !is.null(result$C) && ncol(result$C) != ncol(B)

  P <- nrow(X)
  Q <- ncol(X)

  ## ---------------------------------------------------------
  ## Labels
  ## ---------------------------------------------------------
  Y_labels <- if (is.null(Y.label)) rownames(X) else Y.label
  X_labels <- if (is.null(X.label)) colnames(X) else X.label

  if (is.null(Y_labels)) Y_labels <- paste0("Y", seq_len(P))
  if (is.null(X_labels)) X_labels <- paste0("Factor", seq_len(Q))

  Y_ids <- .nmfkc_dot_sanitize_id(paste0("Y_", seq_len(P)))
  X_ids <- .nmfkc_dot_sanitize_id(paste0("X_", seq_len(Q)))

  ## ---------------------------------------------------------
  ## Covariates and tri-factorization
  ## ---------------------------------------------------------
  if (hasA) {
    C <- as.matrix(result$C)          # Q x R
    A_cols <- ncol(C)
    A_labels <- if (is.null(A.label)) colnames(C) else A.label
    if (is.null(A_labels)) A_labels <- paste0("A", seq_len(A_cols))
    A_ids <- .nmfkc_dot_sanitize_id(paste0("A_", seq_len(A_cols)))

    ## Combined mapping for type = "YA"
    XC_mat <- X %*% C   # P x R

  } else if (type == "YX") {
    ## No A block needed
    C <- NULL
    A_cols <- 0L
    A_labels <- NULL
    A_ids <- NULL
    XC_mat <- NULL

  } else {
    stop("The model structure (matrix C) is incompatible with type 'YXA' or 'YA'.")
  }

  ## ---------------------------------------------------------
  ## Sign mode of C (= Theta). When NULL, auto-detect from the fit
  ## (result$C.signed) or from negative entries in C / XC. In signed mode the
  ## threshold is an absolute-value cut (|coef| >= threshold), edge widths use
  ## |coef|, and negative edges are coloured to distinguish them. The basis X
  ## is always non-negative, so X -> Y edges are unaffected.
  ## ---------------------------------------------------------
  if (is.null(C.signed)) {
    C.signed <- isTRUE(result$C.signed) ||
      (!is.null(C)      && any(C      < 0, na.rm = TRUE)) ||
      (!is.null(XC_mat) && any(XC_mat < 0, na.rm = TRUE))
  }
  C.signed <- isTRUE(C.signed)
  ## magnitude used for thresholding and penwidth scaling (|.| in signed mode)
  mag <- if (C.signed) function(v) abs(v) else function(v) v
  ## edge line style by sign (negative -> dashed) in signed mode; colour stays black
  estyle <- function(v) if (C.signed && is.finite(v) && v < 0) "dashed" else "solid"

  ## ---------------------------------------------------------
  ## Filter isolated nodes (hide.isolated)
  ## ---------------------------------------------------------
  idx_Y <- seq_len(P)
  idx_A <- if (hasA) seq_len(A_cols) else integer(0)
  if (isTRUE(hide.isolated)) {
    if (type == "YX" || type == "YXA") {
      # Y is connected via X: X[i, ] >= threshold
      used_Y <- apply(X, 1L, function(row) any(row >= threshold, na.rm = TRUE))
      idx_Y <- which(used_Y)
    } else if (type == "YA" && !is.null(XC_mat)) {
      # Y is connected via XC: |XC_mat[i, ]| >= threshold
      used_Y <- apply(XC_mat, 1L, function(row) any(mag(row) >= threshold, na.rm = TRUE))
      idx_Y <- which(used_Y)
    }
    if (hasA && type %in% c("YXA", "YA")) {
      if (type == "YXA" && !is.null(C)) {
        # A is connected via C: |C[, k]| >= threshold
        used_A <- apply(C, 2L, function(col) any(mag(col) >= threshold, na.rm = TRUE))
        idx_A <- which(used_A)
      } else if (type == "YA" && !is.null(XC_mat)) {
        # A is connected via XC: |XC_mat[, k]| >= threshold
        used_A <- apply(XC_mat, 2L, function(col) any(mag(col) >= threshold, na.rm = TRUE))
        idx_A <- which(used_A)
      }
    }
  }

  ## ---------------------------------------------------------
  ## DOT header
  ## ---------------------------------------------------------
  scr <- .nmfkc_dot_header(graph_name = "NMF", rankdir = rankdir)

  ## ---------------------------------------------------------
  ## Y node cluster
  ## ---------------------------------------------------------
  scr <- paste0(
    scr,
    '\n  // Output variables (Y)\n',
    .nmfkc_dot_cluster_nodes(
      cluster_id  = "Y",
      title       = Y.title,
      node_ids    = Y_ids[idx_Y],
      node_labels = Y_labels[idx_Y],
      shape       = "box",
      fill        = fill,
      fillcolor   = "lightblue",
      line_width  = 1.5
    )
  )

  ## ---------------------------------------------------------
  ## X node cluster
  ## (Hidden when type = "YA")
  ## ---------------------------------------------------------
  if (type != "YA") {
    scr <- paste0(
      scr,
      '\n  // Latent factors (X)\n',
      .nmfkc_dot_cluster_nodes(
        cluster_id  = "X",
        title       = X.title,
        node_ids    = X_ids,
        node_labels = X_labels,
        shape       = "ellipse",
        fill        = fill,
        fillcolor   = "wheat",
        line_width  = 1.0
      )
    )
  }

  ## ---------------------------------------------------------
  ## A node cluster
  ## (Only for "YA" and "YXA")
  ## ---------------------------------------------------------
  if (type != "YX" && hasA) {
    scr <- paste0(
      scr,
      '\n  // Covariates (A)\n',
      .nmfkc_dot_cluster_nodes(
        cluster_id  = "A",
        title       = A.title,
        node_ids    = A_ids[idx_A],
        node_labels = A_labels[idx_A],
        shape       = "box",
        fill        = fill,
        fillcolor   = "lightcoral",
        line_width  = 1.5
      )
    )
  }

  ## ---------------------------------------------------------
  ## Significance stars for C edges (from nmfkc.inference)
  ## ---------------------------------------------------------
  C_stars <- NULL
  C_show  <- NULL
  if (!is.null(result$coefficients) && hasA) {
    C_stars <- matrix("", nrow = Q, ncol = A_cols)
    C_pval  <- matrix(NA_real_, nrow = Q, ncol = A_cols)
    cf <- result$coefficients
    basis_names <- rownames(C)
    cov_names   <- colnames(C)
    ## nmfkc.net.inference names the two factors Basis.row / Basis.col, because
    ## there both axes are bases.  Its result still inherits class "nmfkc", so it
    ## can reach here; without this fallback match() returned integer(0) and the
    ## `if` below errored with "argument is of length zero".  Same fallback that
    ## nmfkc.net.DOT already carries.
    row_col <- if ("Basis.row" %in% names(cf)) "Basis.row" else "Basis"
    col_col <- if ("Basis.col" %in% names(cf)) "Basis.col" else "Covariate"
    for (k in seq_len(nrow(cf))) {
      q <- match(cf[[row_col]][k], basis_names)
      r <- match(cf[[col_col]][k], cov_names)
      if (!is.na(q) && !is.na(r) && !is.na(cf$p_value[k])) {
        p <- cf$p_value[k]
        C_pval[q, r] <- p
        if (p < 0.001)      C_stars[q, r] <- "***"
        else if (p < 0.01)  C_stars[q, r] <- "**"
        else if (p < 0.05)  C_stars[q, r] <- "*"
      }
    }
    if (!is.null(sig.level)) {
      C_show <- !is.na(C_pval) & C_pval < sig.level
    }
  }

  ## ---------------------------------------------------------
  ## Edge defaults
  ## ---------------------------------------------------------
  scr <- paste0(
    scr,
    '\n  edge [fontname="Arial", fontsize=8, arrowhead=open];\n'
  )

  pw     <- .nmfkc_dot_penwidth
  digits <- .nmfkc_dot_digits_from_threshold(threshold)
  fmtc   <- function(x) .nmfkc_dot_format_coef(x, digits)


  ## =========================================================
  ## Case 1: Full tri-factorization (A → X → Y)
  ## =========================================================
  if (type == "YXA") {

    ## ---- A → X (C) edges ----
    scr <- paste0(
      scr,
      '\n  // A -> X edges (C)\n',
      '  edge [color=black, fontcolor=black, style=solid];\n'
    )

    max_C <- suppressWarnings(max(mag(C), na.rm = TRUE))
    if (is.finite(max_C) && max_C > 0) {
      for (q in seq_len(Q)) {
        for (k in idx_A) {
          val <- C[q, k]
          show <- if (!is.null(C_show)) C_show[q, k]
                  else is.finite(val) && mag(val) >= threshold
          if (show) {
            pen <- pw(mag(val), max_C, weight_scale_ax)
            lab <- fmtc(val)
            if (!is.null(C_stars)) lab <- paste0(lab, C_stars[q, k])
            sty <- estyle(val)
            scr <- paste0(
              scr,
              sprintf('  %s -> %s [label="%s", penwidth=%.2f, style=%s];\n',
                      A_ids[k], X_ids[q], lab, pen, sty)
            )
          }
        }
      }
    }

    ## ---- X → Y edges ----
    scr <- paste0(
      scr,
      '\n  // X -> Y edges (X)\n',
      '  edge [color="gray0", fontcolor="gray0", style=solid];\n'
    )

    max_X <- suppressWarnings(max(X, na.rm = TRUE))
    if (is.finite(max_X) && max_X > 0) {
      for (i in idx_Y) {
        for (j in seq_len(Q)) {
          val <- X[i, j]
          if (is.finite(val) && val >= threshold) {
            pen <- pw(val, max_X, weight_scale_xy)
            lab <- fmtc(val)
            scr <- paste0(
              scr,
              sprintf('  %s -> %s [label="%s", penwidth=%.2f];\n',
                      X_ids[j], Y_ids[i], lab, pen)
            )
          }
        }
      }
    }


    ## =========================================================
    ## Case 2: Direct regression view (A → Y)
    ## =========================================================
  } else if (type == "YA") {

    scr <- paste0(
      scr,
      '\n  // A -> Y edges (X %*% C)\n',
      '  edge [color=black, fontcolor=black, style=solid];\n'
    )

    max_XC <- suppressWarnings(max(mag(XC_mat), na.rm = TRUE))
    if (is.finite(max_XC) && max_XC > 0) {
      for (i in idx_Y) {
        for (k in idx_A) {
          val <- XC_mat[i, k]
          if (is.finite(val) && mag(val) >= threshold) {
            pen <- pw(mag(val), max_XC, weight_scale_ay)
            lab <- fmtc(val)
            sty <- estyle(val)
            scr <- paste0(
              scr,
              sprintf('  %s -> %s [label="%s", penwidth=%.2f, style=%s];\n',
                      A_ids[k], Y_ids[i], lab, pen, sty)
            )
          }
        }
      }
    }


    ## =========================================================
    ## Case 3: Standard NMF (X → Y)
    ## =========================================================
  } else if (type == "YX") {

    scr <- paste0(
      scr,
      '\n  // X -> Y edges (X)\n',
      '  edge [color="gray0", fontcolor="gray0", style=solid];\n'
    )

    max_X <- suppressWarnings(max(X, na.rm = TRUE))
    if (is.finite(max_X) && max_X > 0) {
      for (i in idx_Y) {
        for (j in seq_len(Q)) {
          val <- X[i, j]
          if (is.finite(val) && val >= threshold) {
            pen <- pw(val, max_X, weight_scale_xy)
            lab <- fmtc(val)
            scr <- paste0(
              scr,
              sprintf('  %s -> %s [label="%s", penwidth=%.2f];\n',
                      X_ids[j], Y_ids[i], lab, pen)
            )
          }
        }
      }
    }
  }

  result <- paste0(scr, "}\n")
  class(result) <- "nmfkc.DOT"
  result
}


#' @title Plot method for nmfkc.DOT objects
#' @description
#' Renders a DOT graph string using \code{DiagrammeR::grViz}.
#' If the \pkg{DiagrammeR} package is not installed, prints the DOT source
#' to the console instead.
#'
#' This method handles all DOT objects produced by the nmfkc package:
#' \code{\link{nmfkc.DOT}}, \code{\link{nmf.rrr.DOT}}, \code{\link{nmf.ffb.DOT}},
#' and \code{\link{nmfkc.ar.DOT}}.
#'
#' @param x An object of class \code{"nmfkc.DOT"} (or a subclass thereof).
#' @param ... Not used.
#' @return Called for its side effect (rendering). Returns \code{x} invisibly.
#' @seealso \code{\link{nmfkc.DOT}}, \code{\link{nmf.rrr.DOT}},
#'   \code{\link{nmf.ffb.DOT}}, \code{\link{nmfkc.ar.DOT}},
#'   \code{\link{print.nmfkc.DOT}}
#' @export
plot.nmfkc.DOT <- function(x, ...) {
  if (requireNamespace("DiagrammeR", quietly = TRUE)) {
    print(DiagrammeR::grViz(as.character(x)))
  } else {
    message("DiagrammeR package not installed. Printing DOT source:")
    cat(as.character(x))
  }
  invisible(x)
}


#' @title Print method for nmfkc.DOT objects
#' @description
#' Writes the Graphviz source out as text.  Without it, printing a DOT object
#' fell to \code{print.default}, which showed the whole graph as one escaped
#' string (\code{[1] "digraph NMF \{\\n ..."}) --- unreadable.  Shared by every
#' \code{*.DOT} function through the common \code{"nmfkc.DOT"} class.
#' @param x An object of class \code{"nmfkc.DOT"} (or a subclass).
#' @param ... Not used.
#' @return \code{x}, invisibly.
#' @seealso \code{\link{plot.nmfkc.DOT}}, \code{\link{nmfkc.DOT}}
#' @export
print.nmfkc.DOT <- function(x, ...) {
  cat(as.character(x))
  cat("# Use plot() to render (requires DiagrammeR),",
      "or write this text to a .dot file.\n")
  invisible(x)
}


#' @title Attach the identification diagnostic to an nmf.ffb.inference result (Internal)
#' @description \eqn{Y_1 \approx X(\Theta_1 Y_1 + \Theta_2 Y_2)} is
#'   \eqn{Y_1 \approx X\Theta A} with \eqn{\Theta = [\Theta_1\ \Theta_2]} and
#'   \eqn{A = [Y_1; Y_2]}, all of \eqn{X}, \eqn{\Theta_1}, \eqn{\Theta_2} non-negative
#'   and the columns of \eqn{X} summing to one, so the diagnostic of
#'   \code{nmfkc.inference()} applies: the bootstrap is conditional on \eqn{\hat X},
#'   and \code{identified.approx} says whether \eqn{(X, \Theta)} is unique up to a
#'   permutation.  The exclusion restriction's zeros in \eqn{\Theta_1} help.  The
#'   full covariance \eqn{\Phi} of the latent errors absorbs any \eqn{T}, so it
#'   adds nothing.  An algebraic check on the point estimate: no re-fits.
#' @param object The inference result.
#' @param Y1,Y2 The data.
#' @param ex The \code{...} list (reads \code{sep.tol}, \code{pin.tol}).
#' @keywords internal
#' @noRd
.ffb.attach.ident <- function(object, Y1, Y2, ex) {
  v <- .ident.verdict(object$X, base::cbind(object$C1, object$C2),
                      base::rbind(base::as.matrix(Y1), base::as.matrix(Y2)),
                      scale.fixed = TRUE,
                      sep.tol = if (!is.null(ex$sep.tol)) ex$sep.tol else 0.999,
                      pin.tol = if (!is.null(ex$pin.tol)) ex$pin.tol else 0.01)
  object$identified.approx <- v$identified.approx
  object$identification    <- v$identification
  object
}

Try the nmfkc package in your browser

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

nmfkc documentation built on Oct. 30, 2026, 9:06 a.m.