R/nmfkc.R

Defines functions print.summary.nmfkc.inference summary.nmfkc.inference nmfkc.inference .nmfkc_dot_penwidth .nmfkc_dot_cluster_nodes .nmfkc_dot_header .nmfkc_dot_sanitize_id .nmfkc_dot_format_coef .nmfkc_dot_digits_from_threshold nmfkc.residual.plot nmfkc.rank nmfkc.criterion nmfkc.ecv nmfkc.cv predict.nmfkc nmfkc.class nmfkc.denormalize nmfkc.normalize print.summary.nmfkc summary.nmfkc plot.nmfkc nmfkc .nmfkc_resolve_formula .nmfkc_parse_formula print.nmf.rank plot.nmf.rank .rank.finish print.nmf.cluster.flow plot.nmf.cluster.flow .flow.colors nmf.cluster.flow .ari .flow.place .nmf.hard.labels print.nmf.cluster.criteria plot.nmf.cluster.criteria nmf.cluster.criteria .nmf.cluster.criteria.coef .ecv.run .ecv.make.folds .print.structure.diagnostics .print.fit.statistics .effective.rank .r.squared.all .cluster.criteria .silhouette.mean .init_X_method .kmeanspp.seed .nndsvdar .nmfkc_init_X nmfkc.kernel.beta.cv nmfkc.kernel.beta.nearest.med nmfkc.kernel nmfkc.kernel.gaussian

Documented in nmf.cluster.criteria nmf.cluster.flow nmfkc nmfkc.class nmfkc.criterion nmfkc.cv nmfkc.denormalize nmfkc.ecv nmfkc.inference nmfkc.kernel nmfkc.kernel.beta.cv nmfkc.kernel.beta.nearest.med nmfkc.kernel.gaussian nmfkc.normalize nmfkc.rank nmfkc.residual.plot plot.nmf.cluster.criteria plot.nmf.cluster.flow plot.nmfkc plot.nmf.rank predict.nmfkc print.nmf.cluster.criteria print.nmf.cluster.flow print.nmf.rank print.summary.nmfkc print.summary.nmfkc.inference summary.nmfkc summary.nmfkc.inference

#' @title Create a Gaussian kernel matrix from covariates
#' @description
#' \code{nmfkc.kernel.gaussian} constructs a Gaussian (RBF) kernel matrix from covariate matrices.
#' The kernel is defined as \eqn{K(u,v) = \exp(-\beta \|u - v\|^2)}.
#' When \code{V} contains \code{NA} values, two methods are available via \code{na.method}:
#' \describe{
#'   \item{\code{"pds"}}{Partial Distance Strategy. Computes the kernel using only observed (non-NA) rows,
#'     with beta adjusted by \eqn{\beta_{adj} = \beta \times K / K_{obs}} where \eqn{K} is the total number of rows
#'     and \eqn{K_{obs}} is the number of observed rows.}
#'   \item{\code{"egk"}}{Expected Gaussian Kernel (Mesquita et al., 2019). Uses a Gaussian Mixture Model (GMM)
#'     to estimate the conditional distribution of missing values given observed values,
#'     then computes the expected kernel value via a Gamma approximation.
#'     Requires \code{gmm.means}, \code{gmm.sigmas}, and \code{gmm.weights} passed through \code{...}.}
#' }
#'
#' @param U Covariate matrix \eqn{U(K,N) = (u_1, \dots, u_N)}. Each row may be normalized in advance.
#' @param V Covariate matrix \eqn{V(K,M) = (v_1, \dots, v_M)}, typically used for prediction. If \code{NULL}, the default is \code{U}. May contain \code{NA} values.
#' @param beta Bandwidth parameter for the Gaussian kernel. Default is \code{0.5}.
#' @param na.method Method for handling \code{NA} values in \code{V}. Either \code{"pds"} or \code{"egk"}. Ignored if \code{V} has no \code{NA}.
#' @param ... Additional arguments for EGK method:
#'   \describe{
#'     \item{\code{gmm.G}}{Number of GMM components for EGK. Default is \code{3} (Mesquita et al., 2019).}
#'   }
#'
#' @return Kernel matrix \eqn{A(N,M)}.
#' @seealso \code{\link{nmfkc.kernel}}, \code{\link{nmfkc.kernel.beta.cv}}, \code{\link{nmfkc.kernel.beta.nearest.med}}
#' @export
#' @source Mesquita, D., Gomes, J. P., & Rodrigues, L. R. (2019).
#'   Gaussian kernels for incomplete data. \emph{Applied Soft Computing}, 77, 356--365.
#' @examples
#' U <- matrix(c(5,10,15,20,25),nrow=1)
#' V <- matrix(1:25,nrow=1)
#' A <- nmfkc.kernel.gaussian(U,V,beta=28/1000)
#' dim(A)
#'
#' # PDS example: V with NA in first row
#' U2 <- matrix(rnorm(20), nrow=2)
#' V2 <- matrix(rnorm(10), nrow=2)
#' V2[1, c(2,4)] <- NA
#' A2 <- nmfkc.kernel.gaussian(U2, V2, beta=0.5, na.method="pds")

nmfkc.kernel.gaussian <- function(U, V = NULL, beta = 0.5,
                                   na.method = c("pds", "egk"), ...){
  extra_args <- base::list(...)
  U <- as.matrix(U); storage.mode(U) <- "double"
  if (is.null(V)) V <- U else V <- as.matrix(V)
  storage.mode(V) <- "double"
  if (nrow(U) == 0) stop("'U' must have at least one row (feature).")
  if (nrow(U) != nrow(V)) stop("'U' and 'V' must have the same number of rows (features).")
  na.method <- match.arg(na.method)

  has_na <- anyNA(V)

  if (!has_na) {
    # --- No NA: fast vectorized computation ---
    G <- crossprod(U, V)
    u2 <- colSums(U * U)
    v2 <- colSums(V * V)
    D2 <- outer(u2, v2, "+") - 2 * G
    D2 <- pmax(D2, 0)
    K <- exp(-beta * D2)
  } else if (na.method == "pds") {
    # --- PDS: Partial Distance Strategy ---
    N <- ncol(U); M <- ncol(V); P <- nrow(U)
    K <- matrix(0, N, M)
    for (j in seq_len(M)) {
      obs <- !is.na(V[, j])
      P_obs <- sum(obs)
      if (P_obs == 0) next
      beta_adj <- beta * (P / P_obs)
      d2_j <- colSums((U[obs, , drop = FALSE] - V[obs, j])^2)
      K[, j] <- exp(-beta_adj * d2_j)
    }
  } else {
    # --- EGK: Expected Gaussian Kernel ---
    if (!requireNamespace("mclust", quietly = TRUE))
      stop("na.method='egk' requires the 'mclust' package. Install it with: install.packages('mclust')")
    gmm_G <- if (!is.null(extra_args$gmm.G)) extra_args$gmm.G else 3
    gmm <- mclust::Mclust(t(U), G = gmm_G, verbose = FALSE)
    gmm_means   <- gmm$parameters$mean
    gmm_sigmas  <- gmm$parameters$variance$sigma
    gmm_weights <- gmm$parameters$pro
    C_gmm <- length(gmm_weights)
    N <- ncol(U); M <- ncol(V); P <- nrow(U)
    K <- matrix(0, N, M)

    for (j in seq_len(M)) {
      obs_idx  <- which(!is.na(V[, j]))
      miss_idx <- which(is.na(V[, j]))
      P_obs  <- length(obs_idx)
      P_miss <- length(miss_idx)

      if (P_miss == 0) {
        # No missing: standard kernel
        d2_j <- colSums((U - V[, j])^2)
        K[, j] <- exp(-beta * d2_j)
        next
      }
      if (P_obs == 0) next

      x_obs <- V[obs_idx, j]

      # Conditional moments from GMM
      resp <- numeric(C_gmm)
      cond_mean_list <- vector("list", C_gmm)
      cond_var_list  <- vector("list", C_gmm)

      for (cc in seq_len(C_gmm)) {
        mu_O <- gmm_means[obs_idx, cc]
        mu_M <- gmm_means[miss_idx, cc]
        Sig_OO <- gmm_sigmas[obs_idx, obs_idx, cc]
        Sig_MO <- gmm_sigmas[miss_idx, obs_idx, cc]
        Sig_OM <- gmm_sigmas[obs_idx, miss_idx, cc]
        Sig_MM <- gmm_sigmas[miss_idx, miss_idx, cc]

        Sig_OO_inv <- solve(Sig_OO)
        diff_o <- x_obs - mu_O
        cond_mean_list[[cc]] <- as.vector(mu_M + Sig_MO %*% Sig_OO_inv %*% diff_o)
        cond_cov <- Sig_MM - Sig_MO %*% Sig_OO_inv %*% Sig_OM
        cond_var_list[[cc]] <- pmax(diag(cond_cov), 0)

        log_lik <- -0.5 * (P_obs * log(2 * pi) +
                            determinant(Sig_OO, logarithm = TRUE)$modulus +
                            sum(diff_o * (Sig_OO_inv %*% diff_o)))
        resp[cc] <- log(gmm_weights[cc]) + log_lik
      }

      # Normalize responsibilities (log-sum-exp trick)
      resp <- exp(resp - max(resp))
      resp <- resp / sum(resp)

      # Mixture moments for missing dimensions
      E_x_miss  <- rep(0, P_miss)
      E_x2_miss <- rep(0, P_miss)
      for (cc in seq_len(C_gmm)) {
        E_x_miss  <- E_x_miss  + resp[cc] * cond_mean_list[[cc]]
        E_x2_miss <- E_x2_miss + resp[cc] * (cond_mean_list[[cc]]^2 + cond_var_list[[cc]])
      }
      Var_x_miss <- pmax(E_x2_miss - E_x_miss^2, 0)

      # Compute EGK for each training sample
      for (i in seq_len(N)) {
        Ez_obs <- sum((U[obs_idx, i] - x_obs)^2)
        diff_miss <- U[miss_idx, i] - E_x_miss
        Ez_miss <- sum(diff_miss^2 + Var_x_miss)
        Ez <- Ez_obs + Ez_miss
        Varz <- sum(4 * diff_miss^2 * Var_x_miss + 2 * Var_x_miss^2)

        if (Varz < 1e-30) {
          K[i, j] <- exp(-beta * Ez)
        } else {
          alpha_g <- Ez^2 / Varz
          beta_g  <- Ez / Varz
          K[i, j] <- (beta_g / (beta_g + beta))^alpha_g
        }
      }
    }
  }

  K[K < 0] <- 0
  dimnames(K) <- list(colnames(U), colnames(V))
  attr(K, "params") <- beta
  attr(K, "kernel") <- "Gaussian"
  attr(K, "function.name") <- "nmfkc.kernel.gaussian"
  if (has_na) attr(K, "na.method") <- na.method
  return(K)
}


#' @title Create a kernel matrix from covariates
#' @description
#' \code{nmfkc.kernel} constructs a kernel matrix from covariate matrices.
#' It supports Gaussian, Exponential, Periodic, Linear, Normalized Linear, and Polynomial kernels.
#'
#' @param U Covariate matrix \eqn{U(K,N) = (u_1, \dots, u_N)}. Each row may be normalized in advance.
#' @param V Covariate matrix \eqn{V(K,M) = (v_1, \dots, v_M)}, typically used for prediction. If \code{NULL}, the default is \code{U}.
#' @param kernel Kernel function to use. Default is \code{"Gaussian"}. Options are \code{"Gaussian"}, \code{"Exponential"}, \code{"Periodic"}, \code{"Linear"}, \code{"NormalizedLinear"}, and \code{"Polynomial"}.
#' @param ... Additional arguments passed to the specific kernel function (e.g., \code{beta}, \code{degree}).
#'
#' @return Kernel matrix \eqn{A(N,M)}.
#' @seealso \code{\link{nmfkc.kernel.gaussian}}, \code{\link{nmfkc.cv}}
#' @export
#' @source Satoh, K. (2024). Applying Non-negative Matrix Factorization with Covariates to the Longitudinal Data as Growth Curve Model.
#'   arXiv preprint arXiv:2403.05359. \url{https://arxiv.org/abs/2403.05359}
#' @examples
#' # Example.
#' Y <- matrix(cars$dist,nrow=1)
#' U <- matrix(c(5,10,15,20,25),nrow=1)
#' V <- matrix(cars$speed,nrow=1)
#' A <- nmfkc.kernel(U,V,beta=28/1000)
#' dim(A)
#' result <- nmfkc(Y,A,rank=1)
#' plot(as.vector(V),as.vector(Y))
#' lines(as.vector(V),as.vector(result$XB),col=2,lwd=2)

nmfkc.kernel <- function(U, V = NULL,
                         kernel = c("Gaussian","Exponential","Periodic",
                                    "Linear","NormalizedLinear","Polynomial"),...){
  k_params <- list(...)
  U <- as.matrix(U); storage.mode(U) <- "double"
  if (is.null(V)) V <- U else V <- as.matrix(V)
  storage.mode(V) <- "double"
  if (nrow(U) == 0) stop("'U' must have at least one row (feature).")
  if (nrow(U) != nrow(V)) stop("'U' and 'V' must have the same number of rows (features).")
  kernel <- match.arg(kernel)
  G <- crossprod(U, V)

  # Determine the specific parameter used for the kernel (e.g., beta or degree)
  # This section extracts the effective parameter value to store in attributes.
  effective_param <- NULL
  if (kernel %in% c("Gaussian", "Exponential")) {
    effective_param <- if (!is.null(k_params$beta)) k_params$beta else 0.5
  } else if (kernel == "Periodic") {
    effective_param <- if (!is.null(k_params$beta)) k_params$beta else c(1,1)
  } else if (kernel == "Polynomial") {
    # For Polynomial, the effective param is (beta, degree) pair
    effective_param <- list(beta = if (!is.null(k_params$beta)) k_params$beta else 0,
                            degree = if (!is.null(k_params$degree)) k_params$degree else 2)
  }

  if (kernel %in% c("Gaussian","Exponential","Periodic")) {
    u2 <- colSums(U * U)
    v2 <- colSums(V * V)
    D2 <- outer(u2, v2, "+") - 2 * G
    D2 <- pmax(D2, 0)
  }

  K <- switch(kernel,
              Gaussian ={
                beta <- if (!is.null(k_params$beta)) k_params$beta else 0.5
                na_method <- if (!is.null(k_params$na.method)) k_params$na.method else "pds"
                KG <- nmfkc.kernel.gaussian(U, V, beta, na.method = na_method,
                                             gmm.G = k_params$gmm.G)
                attr(KG, "function.name") <- "nmfkc.kernel"
                KG
              },
              Exponential = {
                beta <- if (!is.null(k_params$beta)) k_params$beta else 0.5
                d <- sqrt(D2)
                exp(-beta * d)
              },
              Periodic = {
                beta <- if (!is.null(k_params$beta)) k_params$beta else c(1,1)
                if (length(beta) < 2L)
                  stop("For 'Periodic', set beta as c(beta1, beta2).")
                d <- sqrt(D2)
                exp(-beta[1] * (sin(beta[2] * d)^2))
              },
              Linear = G,
              NormalizedLinear = {
                nu <- sqrt(colSums(U * U))
                nv <- sqrt(colSums(V * V))
                nu[nu == 0] <- .Machine$double.eps
                nv[nv == 0] <- .Machine$double.eps
                G / outer(nu, nv)
              },
              Polynomial = {
                beta <- if (!is.null(k_params$beta)) k_params$beta else 0
                degree <- if (!is.null(k_params$degree)) k_params$degree else 2
                (G + beta)^degree
              }
  )
  K[K < 0] <- 0
  dimnames(K) <- list(colnames(U), colnames(V))

  # --- Store Kernel Metadata as Attributes (NEW BLOCK) ---
  # These attributes allow nmfkc to identify the kernel type and parameters used.
  attr(K, "params") <- effective_param
  attr(K, "kernel") <- kernel
  attr(K, "function.name") <- "nmfkc.kernel"
  # ------------------------------------------------------

  return(K)
}



#' @title Estimate Gaussian/RBF kernel parameter beta from covariates (supports landmarks)
#'
#' @description
#' Computes a data-driven reference scale for the Gaussian/RBF kernel from covariates
#' using a robust "median nearest-neighbor (or nearest-landmark) distance" heuristic,
#' and returns the corresponding kernel parameter \eqn{\beta}.
#'
#' The Gaussian/RBF kernel is assumed to be written in the form
#' \deqn{k(u,v) = \exp\{-\beta \|u-v\|^2\} = \exp\{-\|u-v\|^2/(2\sigma^2)\},}
#' hence \eqn{\beta = 1/(2\sigma^2)}. This function first estimates a typical distance
#' scale \eqn{\sigma_0} by the median of distances, then sets \eqn{\beta_0 = 1/(2\sigma_0^2)}.
#'
#' If \code{Uk} is \code{NULL}, \eqn{\sigma_0} is estimated as the median of
#' nearest-neighbor distances within \code{U} (excluding self-distance).
#' If \code{Uk} is provided, \eqn{\sigma_0} is estimated as the median of
#' nearest-landmark distances from each sample in \code{U} to its closest landmark in \code{Uk}.
#'
#' To control memory usage for large \code{N} (and \code{M}), distances are computed in blocks.
#' Optionally, columns of \code{U} can be randomly subsampled via \code{sample.size} to reduce cost.
#'
#' @details
#' \strong{Candidate grid:}
#' Along with \code{beta}, the function returns \code{beta_candidates}, a
#' logarithmic grid suitable for cross-validation.  The grid is symmetric on
#' the bandwidth scale \eqn{\sigma} around \eqn{\sigma_0}:
#' \deqn{\sigma = \sigma_0 \times 10^{t},}
#' and since \eqn{\beta = 1/(2\sigma^2)}, this corresponds to
#' \eqn{\beta = \beta_0 \times 10^{-2t}}.
#'
#' The grid of \eqn{t} values can be customized through the hidden argument
#' \code{candidates} (passed via \code{...}):
#' \itemize{
#'   \item \code{"7points"} (default): \eqn{t \in \{-1,-2/3,-1/3,0,1/3,2/3,1\}}
#'         (7 candidates spanning one decade, matches the grid used in the
#'         RFF-NMF research memo).
#'   \item \code{"4points"}: \eqn{t \in \{-1/2, 0, 1/2, 1\}} yielding
#'         \eqn{\beta_0 \times 10^{(1,0,-1,-2)}} (the legacy short grid).
#'   \item A numeric vector: user-specified \eqn{t} values.  The grid returned
#'         is \eqn{\beta_0 \times 10^{-2t}}.
#' }
#'
#' Prior to version 0.6.8, the grid depended on whether \code{Uk} was
#' supplied (4 candidates for \code{Uk = NULL}, 7 for supplied \code{Uk}).
#' The current implementation unifies both branches via \code{candidates}.
#'
#' \strong{Notes:}
#' \itemize{
#'   \item When \code{Uk} is identical to \code{U}, the function detects this case and excludes
#'         self-distances (distance 0) to avoid \eqn{\sigma_0=0}.
#'   \item \code{sample.size} performs random subsampling without setting a seed. For reproducible
#'         results, set \code{set.seed()} before calling this function.
#' }
#'
#' @param U A numeric matrix of covariates (\code{K x N}); columns are samples.
#' @param Uk An optional numeric matrix of landmarks (\code{K x M}); columns are landmark points.
#'   If provided, distances are computed from samples in \code{U} to landmarks in \code{Uk}.
#' @param block.size Integer. Number of columns of \code{U} processed per block when computing
#'   distances (controls memory usage). If \code{N <= 1000}, it is automatically set to \code{N}.
#' @param block.size.Uk Integer. Number of columns of \code{Uk} processed per block when \code{Uk}
#'   is not \code{NULL} (controls memory usage). If \code{M <= 2000}, it is automatically set to \code{M}.
#' @param sample.size Integer or \code{NULL}. If not \code{NULL}, randomly subsamples this many columns
#'   of \code{U} (without replacement) before computing distances, to reduce computational cost.
#' @param ... Additional arguments.  Hidden option \code{candidates} controls
#'   the candidate grid: one of \code{"7points"} (default), \code{"4points"},
#'   or a numeric vector of \eqn{t} values.  See Details.
#'
#' @return A list with elements:
#' \itemize{
#'   \item \code{beta}: Estimated kernel parameter \eqn{\beta_0 = 1/(2\sigma_0^2)}.
#'   \item \code{beta_candidates}: Numeric vector of candidate \eqn{\beta} values (logarithmic grid)
#'         intended for cross-validation.
#'   \item \code{dist_median}: The estimated distance scale \eqn{\sigma_0} (median of nearest-neighbor
#'         or nearest-landmark distances).
#'   \item \code{block.size.used}: The effective block size(s) used. Either a scalar (no \code{Uk}) or
#'         a named vector \code{c(U=..., Uk=...)} when \code{Uk} is provided.
#'   \item \code{sample.size.used}: The number of columns of \code{U} actually used (after subsampling).
#'   \item \code{uk_is_u}: Logical flag indicating whether \code{Uk} was detected as identical to \code{U}
#'         (only returned when \code{Uk} is provided).
#' }
#'
#' @examples
#' # Basic (nearest-neighbor within U)
#' U <- matrix(runif(20), nrow = 2)
#' beta_info <- nmfkc.kernel.beta.nearest.med(U)
#' beta0 <- beta_info$beta
#' betas <- beta_info$beta_candidates
#'
#' # With landmarks (nearest-landmark distances)
#' Uk <- matrix(runif(10), nrow = 2)
#' \donttest{
#' beta_info2 <- nmfkc.kernel.beta.nearest.med(U, Uk)
#' }
#'
#' @seealso \code{\link{nmfkc.kernel.gaussian}}, \code{\link{nmfkc.kernel.beta.cv}}
#' @export
nmfkc.kernel.beta.nearest.med <- function(
    U,
    Uk = NULL,
    block.size = 1000,
    block.size.Uk = 2000,
    sample.size = NULL,
    ...
){
  extra_bn <- list(...)
  if (!is.null(extra_bn$block.size)) block.size <- extra_bn$block.size
  if (!is.null(extra_bn$block.size.Uk)) block.size.Uk <- extra_bn$block.size.Uk
  if (!is.null(extra_bn$sample.size)) sample.size <- extra_bn$sample.size

  ## Hidden option `candidates` controls the bandwidth grid (see @details).
  ## Default "7points" unifies the legacy 4/7 branch inconsistency.
  candidates <- if (!is.null(extra_bn$candidates)) extra_bn$candidates else "7points"

  ## ---- resolve the t-grid from `candidates` ----
  t_grid <- if (is.character(candidates) && length(candidates) == 1L) {
    switch(candidates,
           "7points" = c(-1, -2/3, -1/3, 0, 1/3, 2/3, 1),
           "4points" = c(-1/2, 0, 1/2, 1),
           stop("'candidates' must be \"7points\", \"4points\", or a numeric vector."))
  } else if (is.numeric(candidates)) {
    as.numeric(candidates)
  } else {
    stop("'candidates' must be \"7points\", \"4points\", or a numeric vector.")
  }
  U <- as.matrix(U)
  storage.mode(U) <- "double"
  N <- ncol(U)
  if (N < 2) stop("U must have at least 2 columns.")

  # ---- optional subsampling over U columns (for speed) ----
  if (!is.null(sample.size)) {
    sample.size <- as.integer(sample.size)
    if (sample.size <= 1) stop("sample.size must be >= 2")
    if (sample.size < N) {
      idxU <- sample.int(N, sample.size)
      U <- U[, idxU, drop = FALSE]
      N <- ncol(U)
    }
  }
  sample.size.used <- N

  # If Uk is NULL, behave like the original function: NN within U (exclude self).
  if (is.null(Uk)) {
    X  <- t(U)                    # N x K
    if (N <= 1000) block.size <- N
    XX <- rowSums(X * X)          # length N
    min_d2 <- rep(Inf, N)

    for (i in seq(1, N, by = block.size)) {
      i2 <- min(i + block.size - 1, N)
      Xi <- X[i:i2, , drop = FALSE]
      Xi_norm <- rowSums(Xi * Xi)

      dist2 <- outer(Xi_norm, XX, "+") - 2 * Xi %*% t(X)
      idx <- i:i2
      dist2[cbind(seq_along(idx), idx)] <- Inf  # exclude self
      dist2[dist2 < 0] <- 0

      nn_local <- apply(dist2, 1, min)
      min_d2[idx] <- pmin(min_d2[idx], nn_local)

      rm(Xi, Xi_norm, dist2); gc(FALSE)
    }

    d_med <- stats::median(sqrt(min_d2))
    if (d_med <= 0) {
      warning("Median nearest-neighbor distance is 0 (identical points exist). Using machine epsilon.")
      d_med <- sqrt(.Machine$double.eps)
    }
    beta  <- 1 / (2 * d_med^2)
    return(list(
      beta = beta,
      beta_candidates = beta * 10^(-2 * t_grid),
      dist_median = d_med,
      block.size.used = block.size,
      sample.size.used = sample.size.used
    ))
  }

  # ---- Uk provided: nearest landmark distance (U vs Uk) ----
  Uk <- as.matrix(Uk)
  storage.mode(Uk) <- "double"
  if (nrow(Uk) != nrow(U)) stop("nrow(Uk) must equal nrow(U).")

  M <- ncol(Uk)
  if (M < 1) stop("Uk must have at least 1 column.")

  # We'll compute, for each column of U, min_j ||u_i - uk_j||^2.
  # Block over U (columns) and optionally over Uk to control memory.
  if (N <= 1000) block.size <- N
  if (M <= 2000) block.size.Uk <- M

  U2  <- colSums(U * U)           # length N
  Uk2 <- colSums(Uk * Uk)         # length M
  min_d2 <- rep(Inf, N)

  # Detect "Uk is U" case (same object / identical values) to exclude self-distance.
  uk_is_u <- (M == N) && isTRUE(all.equal(Uk, U, check.attributes = FALSE))

  for (i in seq(1, N, by = block.size)) {
    i2 <- min(i + block.size - 1, N)
    Ui <- U[, i:i2, drop = FALSE]
    Ui2 <- U2[i:i2]

    # we may need to accumulate min over Uk blocks
    cur_min <- rep(Inf, ncol(Ui))

    for (j in seq(1, M, by = block.size.Uk)) {
      j2 <- min(j + block.size.Uk - 1, M)
      Ukj <- Uk[, j:j2, drop = FALSE]
      Ukj2 <- Uk2[j:j2]

      # dist2: (block_U) x (block_Uk)
      # dist2 = ||u||^2 + ||uk||^2 - 2 uk^T u
      G <- crossprod(Ukj, Ui)  # (block_Uk) x (block_U)
      dist2 <- outer(Ui2, Ukj2, "+") - 2 * t(G)
      dist2[dist2 < 0] <- 0

      # exclude self-distances only when Uk == U
      if (uk_is_u) {
        # global indices in U: col(Ui) = (i..i2), col(Ukj) = (j..j2)
        # where they overlap, set that cell to Inf
        gi <- i:i2
        gj <- j:j2
        common <- intersect(gi, gj)
        if (length(common) > 0) {
          # map global index -> local positions
          li <- match(common, gi)
          lj <- match(common, gj)
          dist2[cbind(li, lj)] <- Inf
        }
      }

      cur_min <- pmin(cur_min, apply(dist2, 1, min))

      rm(Ukj, Ukj2, G, dist2); gc(FALSE)
    }

    min_d2[i:i2] <- pmin(min_d2[i:i2], cur_min)
    rm(Ui, Ui2, cur_min); gc(FALSE)
  }

  d_med <- stats::median(sqrt(min_d2))
  if (d_med <= 0) {
    warning("Median nearest-neighbor distance is 0 (identical points exist). Using machine epsilon.")
    d_med <- sqrt(.Machine$double.eps)
  }
  beta  <- 1 / (2 * d_med^2)

  list(
    beta = beta,
    beta_candidates = beta * 10^(-2 * t_grid),
    dist_median = d_med,
    block.size.used = c(U = block.size, Uk = block.size.Uk),
    sample.size.used = sample.size.used,
    uk_is_u = uk_is_u
  )
}





#' @title Optimize beta of the Gaussian kernel function by cross-validation
#' @description
#' \code{nmfkc.kernel.beta.cv} selects the optimal beta parameter of the kernel function by applying cross-validation over a set of candidate values.
#'
#' @param Y Observation matrix \eqn{Y(P,N)}.
#' @param rank Rank of the basis matrix.
#' @param U Covariate matrix \eqn{U(K,N) = (u_1, \dots, u_N)}. Each row may be normalized in advance.
#' @param V Covariate matrix \eqn{V(K,M) = (v_1, \dots, v_M)}, typically used for prediction. If \code{NULL}, the default is \code{U}.
#' @param beta A numeric vector of candidate kernel parameters to evaluate via cross-validation.
#' @param plot Logical. If TRUE (default), plots the objective function values for each candidate \code{beta}.
#' @param ... Additional arguments passed to \code{nmfkc.cv}.
#'
#' @return A list with components:
#' \item{beta}{The beta value that minimizes the cross-validation objective function.}
#' \item{objfunc}{Objective function values for each candidate \code{beta}.}
#' @seealso \code{\link{nmfkc.kernel.gaussian}}, \code{\link{nmfkc.kernel.beta.nearest.med}},
#'   \code{\link{nmfkc.kernel}}
#' @export
#' @examples
#' # Example.
#' Y <- matrix(cars$dist,nrow=1)
#' U <- matrix(c(5,10,15,20,25),nrow=1)
#' V <- matrix(cars$speed,nrow=1)
#' nmfkc.kernel.beta.cv(Y,rank=1,U,V,beta=25:30/1000)
#' A <- nmfkc.kernel(U,V,beta=28/1000)
#' result <- nmfkc(Y,A,rank=1)
#' plot(as.vector(V),as.vector(Y))
#' lines(as.vector(V),as.vector(result$XB),col=2,lwd=2)

nmfkc.kernel.beta.cv <- function(Y,rank=2,U,V=NULL,beta=NULL,plot=TRUE,...){
  extra_args <- list(...)
  if (!is.null(extra_args$Q)) rank <- extra_args$Q
  Q <- rank
  kernel_arg_names <- names(formals(nmfkc.kernel))
  cv_arg_names <- names(formals(nmfkc.cv))
  kernel_args_for_call <- extra_args[names(extra_args) %in% kernel_arg_names]
  cv_args_for_call <- extra_args[names(extra_args) %in% cv_arg_names]

  if(is.null(beta)){
    if(is.null(V)) V <- U
    med_args <- c(list(U = V), extra_args[names(extra_args) %in% names(formals(nmfkc.kernel.beta.nearest.med))])
    result.beta <- do.call("nmfkc.kernel.beta.nearest.med", med_args)
    beta <- result.beta$beta_candidates
    if (is.null(beta) || length(beta) == 0) stop("Failed to determine beta candidates from nearest-neighbor median.")
  }
  objfuncs <- numeric(length(beta))
  for(i in seq_along(beta)){
    start.time <- Sys.time()
    message(paste0("beta=",beta[i],"..."),appendLF=FALSE)

    kernel_args <- c(
      list(U = U, V = V, beta = beta[i]),
      kernel_args_for_call
    )
    A <- do.call("nmfkc.kernel", kernel_args)

    cv_args <- c(
      list(Y = Y, A = A, Q = Q),
      cv_args_for_call
    )
    result <- do.call("nmfkc.cv", cv_args)

    objfuncs[i] <- result$objfunc
    end.time <- Sys.time()
    diff.time <- difftime(end.time,start.time,units="sec")
    diff.time.st <- ifelse(diff.time<=180,paste0(round(diff.time,1),"sec"),
                           paste0(round(diff.time/60,1),"min"))
    message(diff.time.st)
  }
  i0 <- which.min(objfuncs)
  beta.best <- beta[i0]
  if(plot){
    plot(beta,objfuncs,type="l",col=2,xlab="beta",ylab="objfunc",log="x")
    graphics::points(beta[i0],objfuncs[i0],cex=3,col=2)
    graphics::text(beta,objfuncs,format(beta,scientific=TRUE,digits=5))
  }
  names(objfuncs) <- beta
  result <- list(beta=beta.best,objfunc=objfuncs)
  return(result)
}






#------------------------------------------------------------------------------
#------------------------------------------------------------------------------
#------------------------------------------------------------------------------
#------------------------------------------------------------------------------
#------------------------------------------------------------------------------
#------------------------------------------------------------------------------
#------------------------------------------------------------------------------
#------------------------------------------------------------------------------
#------------------------------------------------------------------------------
#------------------------------------------------------------------------------




#' @title Initialize W (X) matrix using NNDSVDar (Vectorized)
#' @description
#'   Internal function to compute the NNDSVDar (Nonnegative Double SVD and Random)
#'   initialization for the basis matrix X. This method fills the zero entries
#'   generated by basic NNDSVD with small random values scaled by the matrix average,
#'   improving stability and convergence.
#' @param Y Input matrix (P x N)
#' @param Q Rank (number of components)
#' @return X (P x Q) non-negative initial basis matrix
#' @keywords internal
#' @noRd
# Internal helper: initialize basis matrix X
.nmfkc_init_X <- function(Y, Q, X.init, Y.weights, seed, nstart, maxit, .eps) {
  # Impute NAs with row means for initialization
  Y_init <- Y
  if (is.matrix(Y.weights) && any(Y.weights == 0)) {
    row_means <- rowSums(Y) / (rowSums(Y.weights) + .eps)
    mask_binary <- (Y.weights > 0)
    idx_missing <- which(!mask_binary, arr.ind = TRUE)
    if (nrow(idx_missing) > 0) {
      Y_init[idx_missing] <- row_means[idx_missing[, 1]]
    }
  }

  if (is.matrix(X.init)) {
    X <- X.init
  } else if (is.character(X.init)) {
    if (!is.null(seed)) set.seed(seed)
    ## runif with nstart > 1 is an nmfkc-specific multi-start feature (10
    ## inner MU iterations per restart to rank the candidate seeds); all
    ## other string methods delegate to the shared .init_X_method().
    if (X.init == "runif" && nstart > 1) {
      best_obj <- Inf
      P <- nrow(Y_init); N <- ncol(Y_init)
      for (s in seq_len(nstart)) {
        Xs <- matrix(stats::runif(P * Q), nrow = P, ncol = Q)
        Bs <- matrix(stats::runif(Q * N), nrow = Q, ncol = N)
        for (iter in 1:10) {
          Bs <- Bs * (t(Xs) %*% Y_init) / (t(Xs) %*% Xs %*% Bs + .eps)
          Xs <- Xs * (Y_init %*% t(Bs)) / (Xs %*% Bs %*% t(Bs) + .eps)
        }
        obj <- sum((Y_init - Xs %*% Bs)^2)
        if (obj < best_obj) { best_obj <- obj; X <- Xs }
      }
    } else {
      ## Seed was already applied just above (`set.seed(seed)`), so pass
      ## seed = NULL to avoid double-setting; pass the outer MU maxit as
      ## kmeans iter.max to preserve pre-refactor behavior.
      X <- .init_X_method(X.init, Y_init, Q,
                          seed = NULL, nstart = nstart, kmeans.maxit = maxit)
    }
  } else if (ncol(Y) == Q) {
    X <- Y_init
  } else {
    X <- matrix(stats::runif(nrow(Y) * Q), nrow = nrow(Y), ncol = Q)
  }
  X
}


.nndsvdar <- function(Y, Q) {
  P <- nrow(Y)
  N <- ncol(Y)
  s <- svd(Y, nu = Q, nv = Q)
  W <- matrix(0, P, Q)
  W[, 1] <- sqrt(s$d[1]) * abs(s$u[, 1])
  if (Q > 1) {
    idx <- 2:Q
    U <- s$u[, idx, drop = FALSE]
    V <- s$v[, idx, drop = FALSE]
    D_sqrt <- sqrt(s$d[idx])
    U_p <- pmax(U, 0)
    U_n <- pmax(-U, 0)
    V_p <- pmax(V, 0)
    V_n <- pmax(-V, 0)
    norm_Up <- sqrt(colSums(U_p^2))
    norm_Un <- sqrt(colSums(U_n^2))
    norm_Vp <- sqrt(colSums(V_p^2))
    norm_Vn <- sqrt(colSums(V_n^2))
    Mp <- norm_Up * norm_Vp
    Mn <- norm_Un * norm_Vn
    eps <- .Machine$double.eps
    norm_Up_safe <- norm_Up; norm_Up_safe[norm_Up == 0] <- eps
    norm_Un_safe <- norm_Un; norm_Un_safe[norm_Un == 0] <- eps
    W_pos <- sweep(U_p, 2, (D_sqrt * Mp / norm_Up_safe), FUN = "*")
    W_neg <- sweep(U_n, 2, (D_sqrt * Mn / norm_Un_safe), FUN = "*")
    use_pos <- (Mp > Mn)
    W_combined <- W_pos
    W_combined[, !use_pos] <- W_neg[, !use_pos]
    W[, idx] <- W_combined
  }
  W[is.nan(W)] <- 0
  W[W < 0] <- 0
  avg_Y <- mean(Y)
  idx_zero <- which(W == 0)
  n_zero <- length(idx_zero)
  fill_values <- stats::runif(n_zero) * avg_Y / 100
  W[idx_zero] <- fill_values
  return(W)
}


## Internal: X initialization by named method.
## Shared across NMF variants (nmfkc, nmf.sem, ...) to avoid duplication
## of the "nndsvd" / "kmeans" / "kmeansar" / "kmeans++" / "runif" dispatch logic.
##
## @param method  One of "nndsvd", "kmeans", "kmeansar", "kmeans++", "runif".
## @param Y       A P x N reference matrix used for SVD (nndsvd) or
##                column clustering (kmeans/kmeansar).  For "runif" only
##                its dimensions matter.  Must contain no NA.
## @param Q       Target rank.
## @param seed    Optional RNG seed (integer).  If not \code{NULL},
##                \code{set.seed(seed)} is called before any random step
##                so the result is reproducible.  Caller may also pre-seed
##                and pass \code{NULL} to leave the stream untouched.
## @param nstart  Number of restarts for \code{\link[stats]{kmeans}}
##                (used only by "kmeans" / "kmeansar").  Default 10.
## @param kmeans.maxit   \code{iter.max} for \code{\link[stats]{kmeans}}.
##                Default 100.
## @return A P x Q non-negative matrix.  Column normalization is the
##         caller's responsibility.
## Internal: k-means++ seeding (Arthur & Vassilvitskii 2007, SODA).
## Chooses k initial centres from the rows of `pts` (N x d) by D^2 weighting:
## the first centre uniformly at random, each subsequent centre with probability
## proportional to its squared distance to the nearest already-chosen centre.
## Returns a k x d matrix of centres (for stats::kmeans(centers = ...)).
.kmeanspp.seed <- function(pts, k) {
  N <- nrow(pts)
  centers <- matrix(0, nrow = k, ncol = ncol(pts))
  i1 <- sample.int(N, 1L)
  centers[1, ] <- pts[i1, ]
  d2 <- colSums((t(pts) - pts[i1, ])^2)          # squared dist to first centre
  for (j in seq_len(k)[-1]) {
    idx <- if (sum(d2) > 0) sample.int(N, 1L, prob = d2) else sample.int(N, 1L)
    centers[j, ] <- pts[idx, ]
    d2 <- pmin(d2, colSums((t(pts) - pts[idx, ])^2))
  }
  centers
}

.init_X_method <- function(method, Y, Q,
                            seed = NULL,
                            nstart = 10,
                            kmeans.maxit = 100) {
  if (!is.null(seed)) set.seed(seed)
  P <- nrow(Y); N <- ncol(Y)
  if (identical(method, "nndsvd")) {
    if (Q <= min(P, N)) .nndsvdar(Y, Q)
    else matrix(stats::runif(P * Q), P, Q)
  } else if (identical(method, "kmeans") || identical(method, "kmeansar")) {
    res.kmeans <- if (N >= Q) {
      tryCatch(stats::kmeans(t(Y), centers = Q,
                             iter.max = kmeans.maxit,
                             nstart = nstart),
               error = function(e) NULL)
    } else NULL
    X <- if (!is.null(res.kmeans)) t(res.kmeans$centers)
         else matrix(stats::runif(P * Q), P, Q)
    if (identical(method, "kmeansar")) {
      avg_Y <- mean(Y)
      idx_zero <- which(X == 0)
      if (length(idx_zero) > 0)
        X[idx_zero] <- stats::runif(length(idx_zero)) * avg_Y / 100
    }
    X
  } else if (identical(method, "kmeans++") || identical(method, "kmeanspp")) {
    ## D^2-weighted seeding then Lloyd refinement (Arthur & Vassilvitskii 2007).
    ## nstart is not used here: the single careful seeding replaces random
    ## restarts. Falls back to the raw seeds if stats::kmeans() fails.
    if (N >= Q) {
      seeds <- .kmeanspp.seed(t(Y), Q)           # Q x P centre matrix
      res <- tryCatch(stats::kmeans(t(Y), centers = seeds, iter.max = kmeans.maxit),
                      error = function(e) NULL)
      if (!is.null(res)) t(res$centers) else t(seeds)
    } else matrix(stats::runif(P * Q), P, Q)
  } else if (identical(method, "runif")) {
    matrix(stats::runif(P * Q), P, Q)
  } else {
    stop(".init_X_method: method must be one of \"nndsvd\", \"kmeans\", ",
         "\"kmeansar\", \"kmeans++\", \"runif\"; got \"", method, "\".")
  }
}



#' @title Mean silhouette width from a distance matrix (Internal)
#' @description
#' Computes the standard mean silhouette width for a hard clustering,
#' given a pre-computed pairwise distance matrix and the cluster labels.
#' For each point \eqn{i}, \eqn{a(i)} is the mean distance to the other
#' members of its own cluster and \eqn{b(i)} is the minimum, over the
#' other clusters, of the mean distance to that cluster; the silhouette
#' is \eqn{(b(i) - a(i)) / \max(a(i), b(i))}.
#'
#' The distance matrix should describe distances in a \strong{fixed}
#' space that does not change with the NMF rank \eqn{Q} -- typically
#' the original data, \code{as.matrix(dist(t(Y)))} -- with the
#' per-sample hard labels \code{B.cluster}.  This is the k-means
#' convention.  (Computing silhouettes in the rank-\eqn{Q} \code{B.prob}
#' simplex instead makes the score monotone in \eqn{Q} and can hide
#' genuine cluster structure.)
#'
#' @param D An \eqn{N \times N} pairwise distance matrix (or object
#'   coercible to one) over the \eqn{N} samples.
#' @param labels Length-\eqn{N} integer cluster labels (\code{NA} allowed;
#'   such samples are dropped).
#'
#' @return The overall mean silhouette width (a single numeric), or
#'   \code{NA} when fewer than two clusters are present.
#' @keywords internal
#' @noRd
.silhouette.mean <- function(D, labels){
  D <- base::as.matrix(D)
  keep <- !is.na(labels)
  D <- D[keep, keep, drop = FALSE]
  labels <- labels[keep]
  clusters <- base::unique(labels)
  if (base::length(clusters) < 2) return(NA_real_)
  N <- base::length(labels)
  si <- base::numeric(N)
  for (i in base::seq_len(N)) {
    own  <- labels[i]
    same <- base::which(labels == own); same <- same[same != i]
    if (base::length(same) == 0) { si[i] <- 0; next }
    a <- base::mean(D[i, same])
    other <- base::setdiff(clusters, own)
    b <- base::min(base::vapply(other,
            function(cl) base::mean(D[i, labels == cl]), base::numeric(1)))
    si[i] <- if (base::max(a, b) > 0) (b - a) / base::max(a, b) else 0
  }
  base::mean(si)
}


#' @title Clustering-quality criteria from data and a coefficient matrix (Internal)
#' @description
#' Shared computation of the sample-clustering diagnostics used by
#' \code{\link{nmfkc.criterion}} (inside \code{nmfkc.rank}) and the
#' user-facing \code{\link{nmf.cluster.criteria}}.  From the original data
#' \eqn{Y} (\eqn{P \times N}) and a \eqn{Q \times N} coefficient/score
#' matrix \eqn{B} it returns:
#' \itemize{
#'   \item \code{dist.cor}: correlation between the sample distances in
#'     data space \code{dist(t(Y))} and in coefficient space
#'     \code{dist(t(B))} (embedding fidelity); always defined.
#'   \item \code{CPCC}: cophenetic correlation of a hierarchical
#'     clustering of \code{dist(t(B))} (Sokal-Rohlf); always defined for
#'     \eqn{Q \ge 2}.
#'   \item \code{silhouette}: mean silhouette width of the hard sample
#'     labels (argmax of the column-normalized \eqn{B}) in data space.
#'     This requires a \strong{non-negative} \eqn{B} (a valid membership
#'     simplex); for a signed \eqn{B} it is \code{NA}.
#' }
#' \code{dist.cor} and \code{CPCC} are distance-based and therefore work
#' for signed \eqn{B} too; only the hard-label \code{silhouette} needs
#' \eqn{B \ge 0}.
#' @param Y Data matrix (\eqn{P \times N}).
#' @param B Coefficient/score matrix (\eqn{Q \times N}).
#' @param labels Optional pre-computed hard labels (length \eqn{N}); when
#'   supplied they are used as-is for the silhouette (e.g.\ the
#'   \code{B.cluster} already held by a fitted \code{nmfkc} model),
#'   otherwise they are derived from \eqn{B} when \eqn{B \ge 0}.
#' @return A list: \code{silhouette}, \code{CPCC}, \code{dist.cor},
#'   \code{cluster} (the hard labels, or \code{NULL}), and \code{hard}
#'   (whether hard clustering was possible).
#' @keywords internal
#' @noRd
.cluster.criteria <- function(Y, B, labels = NULL) {
  Y <- base::as.matrix(Y); B <- base::as.matrix(B)
  Q <- base::nrow(B)
  dY <- stats::dist(base::t(Y))
  dB <- stats::dist(base::t(B))
  dist.cor <- stats::cor(base::as.vector(dY), base::as.vector(dB))
  CPCC <- if (Q >= 2) {
    coph <- stats::cophenetic(stats::hclust(dB))
    stats::cor(base::as.vector(dB), base::as.vector(coph))
  } else NA_real_
  nonneg <- base::all(base::is.finite(B)) && base::all(B >= 0)
  if (base::is.null(labels) && nonneg) {
    B.prob <- base::t(base::t(B) / (base::colSums(B) + .Machine$double.eps))
    labels <- base::apply(B.prob, 2, base::which.max)
    labels[base::colSums(B.prob) == 0] <- NA
  }
  silhouette <- if (!base::is.null(labels))
    .silhouette.mean(dY, labels) else NA_real_
  base::list(silhouette = silhouette, CPCC = CPCC, dist.cor = dist.cor,
             cluster = labels, hard = nonneg)
}





#' @title Unified R-squared computation (3 variants)
#' @description
#' Internal helper used by every NMF function in the package to report
#' three goodness-of-fit summaries on the same scale:
#' \itemize{
#'   \item \code{r.squared}: Pearson \eqn{\mathrm{cor}(Y, \widehat{Y})^2},
#'         scale-invariant, in \eqn{[0, 1]}.
#'   \item \code{r.squared.uncentered}: uncentered \eqn{R^2}
#'         \eqn{1 - \|Y - \widehat{Y}\|_F^2 / \|Y\|_F^2}.  Baseline is
#'         the zero matrix (natural for non-negative factorizations
#'         without an intercept).  Matches the "uncentered \eqn{R^2}"
#'         of intercept-free regression.  In \eqn{(-\infty, 1]}.
#'   \item \code{r.squared.centered}: row-mean centered
#'         \eqn{1 - \|Y - \widehat{Y}\|_F^2 / \|Y - \bar Y_{p\cdot}\|_F^2},
#'         where \eqn{\bar Y_{p\cdot}} is the per-row mean over valid
#'         entries.  Equivalent to the multivariate regression
#'         \eqn{R^2} where each row is treated as one response.  In
#'         \eqn{(-\infty, 1]}.
#' }
#' All three respect the optional non-negative weight matrix
#' \code{Y.weights}: cells with \code{Y.weights == 0} (the standard
#' NA-mask / hold-out convention) are excluded from the sums.
#' @param Y observed matrix (\eqn{P \times N}).
#' @param Y_hat fitted matrix (\eqn{P \times N}).
#' @param Y.weights optional non-negative weight matrix of the same
#'   dimension as \code{Y}.  \code{NULL} means all weights are 1.
#' @return A list with components \code{r.squared}, \code{r.squared.uncentered},
#'   \code{r.squared.centered}, each a length-1 numeric (\code{NA_real_}
#'   when ill-defined, e.g.\ \code{||Y||_F = 0} or fewer than two valid
#'   entries).
#' @keywords internal
#' @noRd
.r.squared.all <- function(Y, Y_hat, Y.weights = NULL) {
  Y     <- base::as.matrix(Y)
  Y_hat <- base::as.matrix(Y_hat)
  P <- base::nrow(Y); N <- base::ncol(Y)

  if (base::is.null(Y.weights)) {
    Wm <- base::matrix(1, P, N)
  } else {
    Wm <- base::as.matrix(Y.weights)
    if (!base::identical(base::dim(Wm), base::c(P, N)))
      base::stop(".r.squared.all: Y.weights must match dim(Y).")
  }
  valid <- (Wm > 0)
  n_valid <- base::sum(valid)

  if (n_valid < 2) {
    return(base::list(r.squared          = NA_real_,
                      r.squared.uncentered     = NA_real_,
                      r.squared.centered = NA_real_))
  }

  y_v    <- Y[valid]
  yhat_v <- Y_hat[valid]
  sse    <- base::sum((y_v - yhat_v)^2)

  ## (A) Pearson cor^2
  r2_cor <- base::tryCatch(stats::cor(y_v, yhat_v)^2,
                           error = function(e) NA_real_)

  ## (C) Uncentered: 1 - SSE / ||Y||_F^2 (valid entries; baseline = 0)
  ss_y   <- base::sum(y_v^2)
  r2_unc <- if (is.finite(ss_y) && ss_y > 0) 1 - sse / ss_y else NA_real_

  ## (D) Row-mean centered: per-row mean over valid entries
  row_means <- base::numeric(P)
  for (p in base::seq_len(P)) {
    vp <- valid[p, ]
    if (base::any(vp)) row_means[p] <- base::mean(Y[p, vp])
    else                row_means[p] <- NA_real_
  }
  ## Build (Y - rowMean)^2 broadcasting row_means down columns, then
  ## sum over valid cells only.  rows with no valid entries contribute 0.
  Y_centered_sq <- (Y - row_means)^2
  Y_centered_sq[!valid] <- 0
  ## Drop rows with NA row_means (i.e., empty rows under masking)
  Y_centered_sq[base::is.na(Y_centered_sq)] <- 0
  ss_centered <- base::sum(Y_centered_sq)
  r2_centered <- if (is.finite(ss_centered) && ss_centered > 0) 1 - sse / ss_centered
                 else NA_real_

  base::list(r.squared          = r2_cor,
             r.squared.uncentered     = r2_unc,
             r.squared.centered = r2_centered)
}


#' @title Effective rank of a coefficient/score matrix (Internal)
#' @description
#' Returns the \strong{effective rank}: \eqn{\exp} of the Shannon
#' entropy of the explained-variance distribution
#' \eqn{p_k = \mathrm{var}(B_{k\cdot}) / \sum_j \mathrm{var}(B_{j\cdot})}
#' over the rows (latent factors) of \code{B}.  By the trace identity
#' \eqn{\sum_k \mathrm{var}(B_{k\cdot}) = \mathrm{tr}(\mathrm{Cov}(B))},
#' each \eqn{p_k} is the exact fraction of the total coefficient
#' variance carried by factor \eqn{k}, so the entropy measures how
#' evenly that variance is spread across factors.  Ranges in
#' \eqn{[1, Q]}.  Returns \code{NA} when there are fewer than two
#' factors (\eqn{Q < 2}) -- the entropy of a single point is trivially
#' 0 -- or fewer than two units (\eqn{N < 2}), or zero total variance.
#' @param B A \eqn{Q \times N} coefficient/score matrix (factors in
#'   rows, units in columns).
#' @return A single numeric (the effective rank), or \code{NA_real_}.
#' @keywords internal
#' @noRd
.effective.rank <- function(B) {
  B <- base::as.matrix(B)
  Q <- base::nrow(B); N <- base::ncol(B)
  if (Q < 2 || N < 2) return(NA_real_)
  v <- base::apply(B, 1, stats::var)
  s <- base::sum(v, na.rm = TRUE)
  if (!base::is.finite(s) || s <= 0) return(NA_real_)
  p <- v / s
  p <- p[p > 0]
  base::exp(-base::sum(p * base::log(p)))
}


#' @title Print a unified "Statistics" block for an NMF summary (Internal)
#' @description
#' Prints the common fit-statistics block shared by the NMF summary
#' methods (objective, the three R-squared variants, residual SE, MAE,
#' and effective rank), reading them from a list/object \code{x}.  Each
#' field is printed only when present and finite, so models that lack a
#' given statistic (e.g.\ \code{nmfkc.net} has no residual SE) simply
#' omit that line.  Labels are left-padded to a common width so values
#' align across all summaries.
#' @param x A list/object with any of \code{objfunc}, \code{r.squared},
#'   \code{r.squared.uncentered}, \code{r.squared.centered},
#'   \code{sigma}, \code{mae}, \code{effective.rank}, \code{rank}.
#' @param header Section header (default \code{"Statistics:"}).
#' @param digits Significant digits for numeric fields.
#' @return \code{NULL}, invisibly (called for its printing side effect).
#' @keywords internal
#' @noRd
.print.fit.statistics <- function(x, header = "Statistics:", digits = 4) {
  base::cat("\n", header, "\n", sep = "")
  w <- 24L
  num <- function(label, val) {
    if (!base::is.null(val) && base::length(val) == 1L && base::is.finite(val))
      base::cat(base::sprintf("  %-*s %s\n", w, label,
                              base::format(val, digits = digits)))
  }
  num("Objective function:",     x$objfunc)
  num("R-squared (cor^2):",      x$r.squared)
  num("R-squared (uncentered):", x$r.squared.uncentered)
  num("R-squared (centered):",   x$r.squared.centered)
  num("Residual Std Error:",     x$sigma)
  num("Mean Absolute Error:",    x$mae)
  if (!base::is.null(x$effective.rank) && base::is.finite(x$effective.rank) &&
      !base::is.null(x$rank) && base::is.finite(x$rank))
    base::cat(base::sprintf("  %-*s %.2f / %d  (%.1f%%)\n", w, "Effective Rank:",
                            x$effective.rank, x$rank,
                            100 * x$effective.rank / x$rank))
  base::invisible(NULL)
}


#' @title Print a unified "Structure Diagnostics" block (Internal)
#' @description
#' Prints the common structure-diagnostics block shared by the NMF
#' summary methods: one sparsity line per factor matrix plus an optional
#' clustering-crispness line.  Sparsities are supplied as a named
#' numeric vector (name = matrix label, value = fraction of near-zero
#' entries); labels are aligned to the same width as
#' \code{\link{.print.fit.statistics}}.  Model-specific extras (value
#' ranges, cluster sizes, \dots) are printed by the caller after this
#' block.
#' @param sparsity Named numeric vector of sparsity fractions in
#'   \eqn{[0, 1]}; names become the row labels (e.g.\ \code{"Basis (X)"}).
#' @param crispness Optional clustering crispness in \eqn{[1/Q, 1]}.
#' @param header Section header (default \code{"Structure Diagnostics:"}).
#' @return \code{NULL}, invisibly.
#' @keywords internal
#' @noRd
.print.structure.diagnostics <- function(sparsity = NULL, crispness = NULL,
                                         header = "Structure Diagnostics:") {
  base::cat("\n", header, "\n", sep = "")
  w <- 24L
  if (!base::is.null(sparsity)) {
    for (lab in base::names(sparsity)) {
      v <- sparsity[[lab]]
      if (!base::is.null(v) && base::is.finite(v))
        base::cat(base::sprintf("  %-*s %.1f%% (< 1e-4)\n", w,
                                base::paste0(lab, " Sparsity:"), 100 * v))
    }
  }
  if (!base::is.null(crispness) && base::is.finite(crispness))
    base::cat(base::sprintf("  %-*s %s (range: 1/Q-1, closer to 1 = more decisive assignment)\n",
                            w, "Clustering Crispness:",
                            base::format(crispness, digits = 4)))
  base::invisible(NULL)
}


#' @title Element-wise CV fold construction (Internal)
#' @description
#' Randomly partitions the non-\code{NA} elements of \code{Y} into
#' \code{nfolds} groups of (nearly) equal size for Wold-style
#' element-wise cross-validation.  Shared by the element-wise
#' \code{*.ecv} functions (\code{\link{nmfkc.ecv}}, \code{nmfae.ecv},
#' \code{nmfkc.signed.ecv}, \code{nmfae.signed.ecv}); the symmetric
#' \code{\link{nmfkc.net.ecv}} uses upper-triangle folds instead.
#' @param Y The observation matrix.
#' @param nfolds Number of folds.
#' @param seed Optional integer seed (set immediately before sampling so
#'   that the partition is reproducible).
#' @return A list of length \code{nfolds}; each element is an integer
#'   vector of linear indices into \code{Y}.
#' @keywords internal
#' @noRd
.ecv.make.folds <- function(Y, nfolds, seed = NULL) {
  if (!base::is.null(seed)) base::set.seed(seed)
  valid_indices <- base::which(!base::is.na(Y))
  n_valid <- base::length(valid_indices)
  perm_indices <- base::sample(valid_indices)
  folds <- base::vector("list", nfolds)
  chunk_size <- n_valid %/% nfolds
  remainder  <- n_valid %% nfolds
  start_idx <- 1L
  for (k in 1:nfolds) {
    current_size <- chunk_size + base::ifelse(k <= remainder, 1L, 0L)
    end_idx <- start_idx + current_size - 1L
    folds[[k]] <- perm_indices[start_idx:end_idx]
    start_idx <- end_idx + 1L
  }
  folds
}


#' @title Run an element-wise CV loop over a configuration list (Internal)
#' @description
#' Shared driver for every element-wise CV function -- the single-rank
#' ones (\code{\link{nmfkc.ecv}}, \code{\link{nmfkc.net.ecv}},
#' \code{nmfkc.signed.ecv}) and the \eqn{(Q, R)}-grid ones
#' (\code{nmfae.ecv}, \code{nmfae.signed.ecv}).  Configurations are
#' addressed by \strong{index}: for each config \eqn{i} (labelled by
#' \code{labels[i]}) and each of \code{nfolds} folds it calls the
#' model-specific \code{run_one(i, k)} closure -- which masks the
#' held-out fold, refits the model at config \eqn{i}, and returns that
#' fold's mean held-out loss -- then aggregates per config
#' (\code{objfunc} = mean over folds, \code{sigma} = its square root for
#' a non-negative objective, else \code{NA}).
#' @param labels Character vector of config labels (e.g.\ \code{"Q=2"} or
#'   \code{"Q=2,R=1"}); its length is the number of configs.
#' @param nfolds Number of folds.
#' @param run_one A function \code{run_one(i, k)} returning the held-out
#'   mean loss for config index \code{i} and fold \code{k}.
#' @param progress Optional \code{function(i, objfunc, sigma)} called
#'   after each config for progress reporting (default none).
#' @return A list with \code{objfunc} (named numeric), \code{sigma}
#'   (named numeric), and \code{objfunc.fold} (named list of per-fold
#'   loss vectors).  Callers using a loss that can be negative (e.g.\ KL)
#'   may additionally force \code{sigma} to \code{NA}.
#' @keywords internal
#' @noRd
.ecv.run <- function(labels, nfolds, run_one, progress = NULL) {
  n   <- base::length(labels)
  obj <- stats::setNames(base::numeric(n), labels)
  sig <- stats::setNames(base::numeric(n), labels)
  fld <- stats::setNames(base::vector("list", n), labels)
  for (i in base::seq_len(n)) {
    objs <- base::numeric(nfolds)
    for (k in 1:nfolds) objs[k] <- run_one(i, k)
    fld[[i]] <- objs
    obj[i]   <- base::mean(objs)
    sig[i]   <- if (base::is.finite(obj[i]) && obj[i] >= 0)
                  base::sqrt(obj[i]) else NA_real_
    if (!base::is.null(progress)) progress(i, obj[i], sig[i])
  }
  base::list(objfunc = obj, sigma = sig, objfunc.fold = fld)
}


#' @title Extract the Q x N coefficient/score matrix of a fitted MU model (Internal)
#' @description
#' Maps each multiplicative-update fit to its natural \eqn{Q \times N}
#' coefficient/score matrix for clustering diagnostics: \code{B} for
#' \code{nmfkc}/\code{nmfkc.signed}, \code{H} for \code{nmfae}/
#' \code{nmfae.signed}, \eqn{X^\top} for \code{nmfkc.net} (node
#' membership), the BLUP scores for \code{nmfre}, and
#' \eqn{C_1 Y_1 + C_2 Y_2} for \code{nmf.ffb}/\code{nmf.sem} (which needs
#' the exogenous block \code{Y2}).
#' @param object A fitted MU model.
#' @param Y The data matrix passed to \code{\link{nmf.cluster.criteria}} (used as
#'   \eqn{Y_1} for \code{nmf.ffb}).
#' @param Y2 Exogenous block, required only for \code{nmf.ffb}/\code{nmf.sem}.
#' @return A \eqn{Q \times N} numeric matrix.
#' @keywords internal
#' @noRd
.nmf.cluster.criteria.coef <- function(object, Y, Y2 = NULL) {
  if (base::inherits(object, "nmfkc.net")) return(base::t(object$X))
  if (base::inherits(object, "nmfae"))     return(object$H)
  if (base::inherits(object, c("nmf.ffb", "nmf.sem"))) {
    if (base::is.null(Y2))
      base::stop("For nmf.ffb / nmf.sem, also pass the exogenous block via Y2=.",
                 call. = FALSE)
    return(object$C1 %*% base::as.matrix(Y) + object$C2 %*% base::as.matrix(Y2))
  }
  if (base::inherits(object, "nmfre"))     return(object$B.blup)
  if (base::inherits(object, "nmfkc"))     return(object$B)
  base::stop("nmf.cluster.criteria(): unsupported object class '",
             base::paste(base::class(object), collapse = "/"), "'.", call. = FALSE)
}


#' @title Sample-clustering quality across ranks
#' @description
#' Computes the clustering-quality criteria \code{silhouette},
#' \code{CPCC}, and \code{dist.cor} for a list of models fitted at
#' different ranks (or a single fit), returning one row per rank.  These
#' are \strong{clustering-stability} diagnostics (how decisively and
#' faithfully the samples cluster), conceptually separate from the
#' rank-selection \code{*.rank} functions (which use r.squared, effective
#' rank, and ECV) and complementary to \code{\link{nmf.cluster.flow}}
#' (which shows how the hard clustering itself changes across ranks).
#'
#' Hard sample clustering requires a non-negative coefficient/score
#' matrix (so the columns form a membership simplex); when a model's
#' coefficient is signed (e.g.\ \code{nmfkc.signed}, \code{nmfae.signed},
#' \code{nmfre} fits whose coefficient has negative entries) the
#' hard-label \code{silhouette} is \code{NA} while the distance-based
#' \code{CPCC} and \code{dist.cor} are still computed.
#'
#' @param fits A list of fitted models, one per rank, all over the same
#'   \eqn{N} individuals (a single fitted model is also accepted and
#'   wrapped automatically).  Supported families: \code{\link{nmfkc}},
#'   \code{\link{nmfkc.signed}}, \code{\link{nmfae}}, \code{nmfae.signed},
#'   \code{\link{nmfkc.net}}, \code{\link{nmfre}}, and
#'   \code{\link{nmf.sem}} / \code{nmf.ffb}.
#' @param Y The original data matrix used to fit the models (\eqn{Y_1}
#'   for \code{nmf.ffb}); required for the data-space distances.
#' @param Y2 Exogenous block, required only for \code{nmf.ffb} /
#'   \code{nmf.sem}.
#' @param names Optional character vector (length \code{length(fits)}) of
#'   x-axis tick labels.  Defaults to each result's \code{$rank}.
#' @param plot Logical; draw the diagnostics plot immediately
#'   (default \code{TRUE}); see \code{\link{plot.nmf.cluster.criteria}}.
#' @param ... When \code{plot = TRUE}, graphical arguments forwarded to
#'   \code{\link{plot.nmf.cluster.criteria}}.
#' @return An object of class \code{"nmf.cluster.criteria"} (returned
#'   invisibly): a list with \code{criteria} (a data frame with one row
#'   per result and columns \code{rank}, \code{silhouette}, \code{CPCC},
#'   \code{dist.cor}, and \code{hard}) and \code{labels} (the x-axis
#'   labels).  Results are kept in the given order (not sorted).
#' @seealso \code{\link{nmf.cluster.flow}}, \code{\link{nmfkc.rank}}
#' @export
#' @examples
#' \donttest{
#' Y <- t(as.matrix(iris[, 1:4]))
#' fits <- lapply(2:6, function(q) nmfkc(Y, Q = q, print.dims = FALSE))
#' cc <- nmf.cluster.criteria(fits, Y, plot = FALSE)
#' cc$criteria
#' plot(cc)
#' }
nmf.cluster.criteria <- function(fits, Y, Y2 = NULL, names = NULL,
                                 plot = TRUE, ...) {
  if (base::missing(Y))
    base::stop("nmf.cluster.criteria() requires the original data matrix Y.", call. = FALSE)
  ## A single fitted model (has a scalar $rank) is wrapped into a list.
  if (!base::is.null(fits$rank)) fits <- base::list(fits)
  if (!base::is.list(fits) || base::length(fits) < 1)
    base::stop("`fits` must be a fitted model or a non-empty list of them.",
               call. = FALSE)
  Y <- base::as.matrix(Y)

  ## Results are taken in the given order (NOT sorted).
  rows <- base::lapply(fits, function(f) {
    B  <- .nmf.cluster.criteria.coef(f, Y, Y2)
    cc <- .cluster.criteria(Y, B)
    rank <- if (!base::is.null(f$rank)) base::as.integer(f$rank)
            else base::nrow(base::as.matrix(B))
    base::data.frame(rank = rank, silhouette = cc$silhouette, CPCC = cc$CPCC,
                     dist.cor = cc$dist.cor, hard = cc$hard)
  })
  criteria <- base::do.call(base::rbind, rows)
  base::rownames(criteria) <- NULL

  R <- base::nrow(criteria)
  if (base::is.null(names)) labels <- base::as.character(criteria$rank)
  else {
    labels <- base::as.character(names)
    if (base::length(labels) != R)
      base::stop("`names` must have length(fits) = ", R, " entries.", call. = FALSE)
  }

  out <- base::list(criteria = criteria, labels = labels)
  base::class(out) <- "nmf.cluster.criteria"
  if (plot) graphics::plot(out, ...)
  base::invisible(out)
}


#' @title Plot clustering-quality criteria across a sequence of fits
#' @description
#' Line plot of \code{silhouette}, \code{CPCC}, and \code{dist.cor}
#' against the result index, for an object from
#' \code{\link{nmf.cluster.criteria}}.  X-axis ticks default to each
#' result's \code{$rank} (overridable via the \code{names} argument of
#' \code{\link{nmf.cluster.criteria}}).
#' @param x An object of class \code{"nmf.cluster.criteria"}.
#' @param main Plot title.
#' @param xlab,ylab Axis labels.
#' @param lwd Line width.
#' @param ... Further arguments passed to the initial
#'   \code{\link[graphics]{plot}}.
#' @return \code{x}, invisibly.
#' @seealso \code{\link{nmf.cluster.criteria}}
#' @export
plot.nmf.cluster.criteria <- function(x, main = "Clustering quality across rank",
                                      xlab = "rank (Q)", ylab = "criterion",
                                      lwd = 2, ...) {
  cr <- x$criteria
  labels <- if (!base::is.null(x$labels)) x$labels else base::as.character(cr$rank)
  xi <- base::seq_len(base::nrow(cr))       # result index on the x-axis
  metrics <- c(silhouette = 7, CPCC = 6, dist.cor = 5)  # name = colour
  yr <- base::range(base::unlist(cr[base::names(metrics)]), na.rm = TRUE)
  if (!base::all(base::is.finite(yr))) yr <- c(0, 1)
  graphics::plot(NA, xlim = base::range(xi), ylim = yr, xaxt = "n",
                 xlab = xlab, ylab = ylab, main = main, ...)
  graphics::axis(1, at = xi, labels = labels)
  for (q in xi) graphics::abline(v = q, col = "gray90", lwd = 0.5)
  for (m in base::names(metrics)) {
    v <- cr[[m]]
    if (base::all(base::is.na(v))) next
    graphics::lines(xi, v, col = metrics[[m]], lwd = lwd)
    graphics::points(xi, v, pch = 16, col = metrics[[m]], cex = 0.8)
  }
  graphics::legend("bottomright", legend = base::names(metrics),
                   col = base::unlist(metrics), lty = 1, lwd = 2,
                   bg = "white", cex = 0.8)
  base::invisible(x)
}


#' @title Print method for nmf.cluster.criteria objects
#' @param x An object of class \code{"nmf.cluster.criteria"}.
#' @param ... Passed to the criteria table's \code{print}.
#' @return \code{x}, invisibly.
#' @export
print.nmf.cluster.criteria <- function(x, ...) {
  labels <- if (!base::is.null(x$labels)) x$labels else base::as.character(x$criteria$rank)
  base::cat("Sample-clustering quality across ", base::nrow(x$criteria),
            " results [", base::paste(labels, collapse = ", "), "]\n", sep = "")
  base::cat("(silhouette / CPCC / dist.cor; silhouette = NA when the coefficient is signed)\n\n")
  base::print(x$criteria, row.names = FALSE, ...)
  base::invisible(x)
}


#' @title Hard cluster labels of a fitted MU model (Internal)
#' @description
#' Returns the per-individual hard cluster labels (argmax of the
#' \eqn{Q \times N} coefficient/score matrix) for a fitted model, used by
#' \code{\link{nmf.cluster.flow}}.  For \code{nmfkc} this equals the
#' stored \code{B.cluster}.
#' @param object A fitted MU model.
#' @return An integer vector of length \eqn{N} (named by the columns of
#'   the coefficient matrix when available).
#' @keywords internal
#' @noRd
.nmf.hard.labels <- function(object) {
  B <- if (base::inherits(object, "nmfkc.net")) base::t(object$X)
       else if (base::inherits(object, "nmfae")) object$H
       else if (base::inherits(object, "nmfre")) object$B.blup
       else if (base::inherits(object, "nmfkc")) object$B
       else base::stop("nmf.cluster.flow(): unsupported model class '",
                       base::paste(base::class(object), collapse = "/"),
                       "'.", call. = FALSE)
  B <- base::as.matrix(B)
  lab <- base::apply(B, 2, base::which.max)
  nm <- base::colnames(B)
  if (!base::is.null(nm)) base::names(lab) <- nm
  lab
}


#' @title Place individuals within a rank, with inter-cluster gaps (Internal)
#' @description
#' Layout for one rank of \code{\link{nmf.cluster.flow}}.  Clusters are
#' stacked in increasing order of the mean key (\code{key}) of their
#' members -- a barycenter ordering that reduces alluvial crossings --
#' and within each cluster the members are sorted by \code{key}.  Members
#' are placed one unit apart, and a \strong{gap} of \eqn{N / k} units
#' (one average cluster's worth, \eqn{k} = number of clusters) is left
#' between consecutive clusters so the cluster boxes are clearly
#' separated.  Positions are normalized per rank in the plot, so the
#' varying total span across ranks does not matter.
#' @param labels Integer cluster labels at this rank (length \eqn{N}).
#' @param key Numeric ordering key (length \eqn{N}); the positions of the
#'   adjacent already-placed rank, or, for the reference rank, the
#'   reference labels themselves (so clusters stack in id order).
#' @return A numeric vector of positions for this rank.
#' @keywords internal
#' @noRd
.flow.place <- function(labels, key) {
  N <- base::length(labels)
  cls <- base::unique(labels)
  bary <- base::vapply(cls, function(c) base::mean(key[labels == c], na.rm = TRUE),
                       base::numeric(1))
  cls <- cls[base::order(bary)]
  gap <- N / base::length(cls)          # inter-cluster gap (one avg cluster)
  y <- base::numeric(N)
  pos <- 0
  for (c in cls) {
    idx <- base::which(labels == c)
    idx <- idx[base::order(key[idx])]
    nc <- base::length(idx)
    y[idx] <- pos + (base::seq_len(nc) - 1)
    pos <- pos + nc + gap               # advance past this cluster + gap
  }
  y
}


#' @title Adjusted Rand Index between two hard clusterings (Internal)
#' @description
#' Standard adjusted Rand index (Hubert & Arabie 1985) measuring the
#' agreement between two label vectors over the same items, used by
#' \code{\link{nmf.cluster.flow}} for adjacent ranks.  \code{NA} items
#' (in either labelling) are dropped pairwise.
#' @param a,b Integer label vectors of equal length.
#' @return The adjusted Rand index in \eqn{[-1, 1]} (\code{NA} when it is
#'   undefined, e.g.\ fewer than two valid items or a degenerate pair).
#' @keywords internal
#' @noRd
.ari <- function(a, b) {
  keep <- !(base::is.na(a) | base::is.na(b))
  a <- a[keep]; b <- b[keep]
  n <- base::length(a)
  if (n < 2) return(NA_real_)
  tab <- base::table(a, b)
  cc <- function(x) base::sum(x * (x - 1) / 2)
  sij <- cc(base::as.vector(tab))
  si  <- cc(base::rowSums(tab))
  sj  <- cc(base::colSums(tab))
  expct <- si * sj / (n * (n - 1) / 2)
  maxi  <- (si + sj) / 2
  if (maxi - expct == 0) return(NA_real_)
  (sij - expct) / (maxi - expct)
}


#' @title Cluster-flow (alluvial) diagram across a sequence of fits
#' @description
#' Visualizes how the hard sample clustering changes across a sequence of
#' fitted models -- typically the same model at increasing ranks, but
#' also different models at the same rank.  Each individual is a line
#' flowing left-to-right across the results (x-axis); its vertical
#' position at each result is determined by its cluster, and clusters are
#' reordered (barycenter method) to reduce crossings.  Lines are coloured
#' by the individual's cluster in the \code{reference} result, so one can
#' see how the reference clusters split or merge.  The adjusted Rand
#' index (ARI) between each pair of adjacent results is printed along the
#' top of the figure.  X-axis ticks default to each result's \code{$rank}
#' and can be overridden with \code{names}.
#'
#' Works for any non-negative multiplicative-update family
#' (\code{nmfkc}, \code{nmfae}, \code{nmfkc.net}, \code{nmfre},
#' and the signed variants); the hard label is the argmax of the
#' coefficient/score matrix.
#'
#' @param fits A list (length \eqn{\ge 2}) of fitted models, all over the
#'   \strong{same} \eqn{N} individuals.  The results are taken in the
#'   given order (\strong{not} sorted), so they may be different ranks or
#'   different models at the same rank.
#' @param reference The \strong{index} (1-based position in \code{fits})
#'   of the result whose clustering defines the line colours.  Defaults
#'   to the central result, \code{floor(length(fits) / 2) + 1} (e.g.\ the
#'   2nd of 2 or 3 results).
#' @param names Optional character vector (length \code{length(fits)}) of
#'   x-axis tick labels.  Defaults to each result's \code{$rank}.
#' @param plot Logical; draw the diagram immediately by calling
#'   \code{\link{plot.nmf.cluster.flow}} (default \code{TRUE}).  Set
#'   \code{FALSE} to only build the object and plot it later.
#' @param ... When \code{plot = TRUE}, graphical arguments forwarded to
#'   \code{\link{plot.nmf.cluster.flow}} (e.g.\ \code{col}, \code{lwd},
#'   \code{xlab}, \code{ylab}, \code{main}).
#' @return An object of class \code{"nmf.cluster.flow"} (returned
#'   invisibly): a list with \code{clusters} (the \eqn{N \times R} table:
#'   rows = individuals, columns = results, entries = cluster number = the
#'   dominant-factor index of each fit, so it matches the factor/basis
#'   numbering of \code{fits}; a factor that never dominates leaves an
#'   empty, unused cluster number),
#'   \code{ypos} (the layout positions), \code{ranks} (each result's
#'   rank), \code{labels} (the x-axis labels), \code{reference} (the
#'   reference index), \code{ref.cluster} (the reference hard labels),
#'   \code{ARI} (adjusted Rand index between each pair of adjacent
#'   results, length \eqn{R - 1}), and \code{colors}
#'   (the default per-individual reference colour).  Call
#'   \code{\link{plot}} on it to (re)draw the diagram.
#' @seealso \code{\link{plot.nmf.cluster.flow}},
#'   \code{\link{nmf.cluster.criteria}}, \code{\link{nmfkc.rank}}
#' @export
#' @examples
#' \donttest{
#' Y <- t(as.matrix(iris[, 1:4]))
#' fits <- lapply(2:6, function(q) nmfkc(Y, Q = q, print.dims = FALSE))
#' fl <- nmf.cluster.flow(fits, reference = 2, plot = FALSE)  # 2nd result
#' head(fl$clusters)
#' plot(fl, lwd = 2, main = "iris cluster flow")
#' }
nmf.cluster.flow <- function(fits, reference = NULL, names = NULL,
                             plot = TRUE, ...) {
  if (!base::is.list(fits) || base::length(fits) < 2)
    base::stop("`fits` must be a list of at least two fitted models.", call. = FALSE)

  lab_list <- base::lapply(fits, .nmf.hard.labels)
  N <- base::length(lab_list[[1]])
  if (base::any(base::vapply(lab_list, base::length, 1L) != N))
    base::stop("all fits must share the same number of individuals (N).", call. = FALSE)
  R <- base::length(fits)

  ## Each result's rank (for the default labels); the given order is kept.
  ranks <- base::vapply(fits, function(f)
    if (!base::is.null(f$rank)) base::as.integer(f$rank) else NA_integer_, 1L)
  if (base::is.null(names)) labels <- base::as.character(ranks)
  else {
    labels <- base::as.character(names)
    if (base::length(labels) != R)
      base::stop("`names` must have length(fits) = ", R, " entries.", call. = FALSE)
  }

  ## The cluster number is the dominant-factor index (argmax of the
  ## coefficient), kept as-is so it matches the factor/basis numbering of
  ## the supplied fits; a factor that never dominates leaves a gap.
  clusters <- base::matrix(base::unlist(lab_list), nrow = N, ncol = R)
  ind_names <- base::names(lab_list[[1]])
  if (base::is.null(ind_names)) ind_names <- base::paste0("i", base::seq_len(N))
  base::rownames(clusters) <- ind_names
  base::colnames(clusters) <- labels

  ## reference = index (1..R) of the result that defines the colours;
  ## default to the central result floor(R/2)+1 (e.g. 2 for 2 or 3 results)
  if (base::is.null(reference)) reference <- base::floor(R / 2) + 1L
  ref_col <- base::as.integer(reference)
  if (base::length(ref_col) != 1L || base::is.na(ref_col) ||
      ref_col < 1L || ref_col > R)
    base::stop("`reference` must be a single result index in 1..", R, ".",
               call. = FALSE)
  ref_lab <- clusters[, ref_col]

  ## --- layout: positions per result, with inter-cluster gaps ---
  ypos <- base::matrix(NA_real_, N, R)
  ypos[, ref_col] <- .flow.place(ref_lab, ref_lab)
  if (ref_col < R) for (q in (ref_col + 1):R)
    ypos[, q] <- .flow.place(clusters[, q], ypos[, q - 1])
  if (ref_col > 1) for (q in (ref_col - 1):1)
    ypos[, q] <- .flow.place(clusters[, q], ypos[, q + 1])

  ## Adjacent-result agreement: ARI between results q and q+1.
  ari <- base::vapply(base::seq_len(R - 1),
                      function(q) .ari(clusters[, q], clusters[, q + 1]),
                      base::numeric(1))
  base::names(ari) <- base::sprintf("%s-%s", labels[-R], labels[-1L])

  out <- base::list(clusters = clusters, ypos = ypos, ranks = ranks,
                    labels = labels, reference = ref_col, ref.cluster = ref_lab,
                    ARI = ari,
                    colors = stats::setNames(.flow.colors(ref_lab)[ref_lab], ind_names))
  base::class(out) <- "nmf.cluster.flow"
  if (plot) graphics::plot(out, ...)
  base::invisible(out)
}


#' @title Reference-cluster colours for a cluster-flow diagram (Internal)
#' @description
#' Maps reference hard labels to colours.  With \code{col = NULL} a
#' strong, well-separated qualitative palette is used (ColorBrewer
#' \dQuote{Dark 2} for up to 8 clusters, HCL \dQuote{Dark 3} beyond, both
#' from the standard \code{grDevices} package); a user \code{col} vector
#' is indexed by cluster id (recycled if too short).
#' @param ref_lab Integer reference cluster labels (length \eqn{N}).
#' @param col Optional colour vector indexed by cluster id.
#' @return The resolved \strong{palette}: a character vector of length
#'   \eqn{K} (one colour per reference cluster).  Index it by cluster id,
#'   e.g.\ \code{pal[ref_lab]} for per-individual colours.
#' @keywords internal
#' @noRd
.flow.colors <- function(ref_lab, col = NULL) {
  K <- base::max(ref_lab, na.rm = TRUE)
  if (base::is.null(col)) {
    n <- base::max(K, 2L)
    col <- if (n <= 8L) grDevices::palette.colors(8L, "Dark 2")[base::seq_len(n)]
           else grDevices::hcl.colors(n, "Dark 3")
  } else if (base::length(col) < K) {
    col <- base::rep_len(col, K)
  }
  col[base::seq_len(K)]
}


#' @title Plot a cluster-flow (alluvial) diagram
#' @description
#' Draws the alluvial / Sankey-style cluster-flow diagram for an object
#' created by \code{\link{nmf.cluster.flow}}.
#' @param x An object of class \code{"nmf.cluster.flow"}.
#' @param col Optional colour vector indexed by \strong{reference}
#'   cluster id (\code{col[k]} colours every individual whose reference
#'   cluster is \code{k}); recycled if shorter than the number of
#'   reference clusters.  Defaults to the object's palette.
#' @param lwd Line width of the flow segments.
#' @param xlab,ylab Axis labels.
#' @param main Plot title.
#' @param ... Further arguments passed to the initial
#'   \code{\link[graphics]{plot}} call.
#' @return \code{x}, invisibly.
#' @seealso \code{\link{nmf.cluster.flow}}
#' @export
plot.nmf.cluster.flow <- function(x, col = NULL, lwd = 1,
                                  xlab = "rank (Q)", ylab = "individuals",
                                  main = "Cluster flow across rank", ...) {
  clusters <- x$clusters; ypos <- x$ypos
  labels <- if (!base::is.null(x$labels)) x$labels else x$ranks
  ref_lab <- x$ref.cluster
  N <- base::nrow(clusters); R <- base::ncol(clusters)
  pal <- .flow.colors(ref_lab, col)   # one colour per reference cluster
  ind_col <- pal[ref_lab]             # per-individual line colour

  ## Normalize each result (column) independently to [0, 1]: the layout
  ## inserts inter-cluster gaps, so the total span differs per result.
  yn <- base::apply(ypos, 2, function(p) {
    rg <- base::range(p)
    if (base::diff(rg) == 0) base::rep(0.5, base::length(p))
    else (p - rg[1]) / base::diff(rg)
  })
  xs <- base::seq_len(R)
  old <- graphics::par(mar = c(4, 4, 4, 2) + 0.1); base::on.exit(graphics::par(old))
  ## extra head-room at the top for the adjacent-result ARI labels
  graphics::plot(NA, xlim = c(1, R), ylim = c(-0.03, 1.12),
                 xaxt = "n", yaxt = "n", xlab = xlab, ylab = ylab, main = main, ...)
  graphics::axis(1, at = xs, labels = labels)

  ## Adjacent-rank ARI, printed between the two columns it compares.
  if (!base::is.null(x$ARI)) for (q in base::seq_len(R - 1)) {
    if (!base::is.finite(x$ARI[q])) next
    lab <- if (q == 1) base::sprintf("ARI=%.2f", x$ARI[q])
           else base::sprintf("%.2f", x$ARI[q])
    graphics::text((xs[q] + xs[q + 1]) / 2, 1.08, lab,
                   cex = 0.85, font = 2, col = "gray25")
  }

  hw  <- 0.10        # half-width of the cluster box (x units)

  ## 1. flow lines + points (drawn first, so the boxes sit in front)
  for (q in 1:(R - 1))
    graphics::segments(xs[q], yn[, q], xs[q + 1], yn[, q + 1],
                       col = ind_col, lwd = lwd)
  for (q in 1:R)
    graphics::points(base::rep(xs[q], N), yn[, q], pch = 16,
                     col = ind_col, cex = 0.5)

  ## 2. foreground box per cluster, sized to the min/max of its members
  ##    (tiny pad so a singleton still shows).  The box is tinted by the
  ##    MAJORITY reference colour among its members; ties are broken in
  ##    favour of the earliest palette entry (smallest reference id).
  pad <- 0.004
  for (q in 1:R) for (c in base::unique(clusters[, q])) {
    sel <- clusters[, q] == c
    ys  <- yn[sel, q]
    tab <- base::table(ref_lab[sel])
    cand <- base::as.integer(base::names(tab)[tab == base::max(tab)])
    majref <- base::min(cand)                 # earliest palette colour on ties
    box_fill <- grDevices::adjustcolor(pal[majref], alpha.f = 0.45)
    graphics::rect(xs[q] - hw, base::min(ys) - pad,
                   xs[q] + hw, base::max(ys) + pad,
                   col = box_fill, border = pal[majref], lwd = 1)
    graphics::text(xs[q], base::mean(ys), c, cex = 1.2, font = 2, col = "gray10")
  }
  base::invisible(x)
}


#' @title Print method for nmf.cluster.flow objects
#' @description
#' Prints a one-line header, the adjacent-result ARI, and the
#' \eqn{N \times R} cluster table (rows = individuals, columns =
#' results, entries = cluster number).  Use \code{\link{plot}} for the
#' diagram.
#' @param x An object of class \code{"nmf.cluster.flow"}.
#' @param ... Passed to the table's \code{print}.
#' @return \code{x}, invisibly.
#' @export
print.nmf.cluster.flow <- function(x, ...) {
  labels <- if (!base::is.null(x$labels)) x$labels else base::as.character(x$ranks)
  base::cat(base::sprintf("Cluster flow: %d individuals across %d results [%s] (reference = result %d: %s)\n",
                          base::nrow(x$clusters), base::length(labels),
                          base::paste(labels, collapse = ", "),
                          x$reference, labels[x$reference]))
  if (!base::is.null(x$ARI)) {
    base::cat("\nAdjacent-result ARI:\n")
    base::print(base::round(x$ARI, 3))
  }
  base::cat("\nClusters (rows = individuals, columns = rank, entries = cluster number):\n")
  base::print(x$clusters, ...)
  base::invisible(x)
}


#' @title Best-rank selection and concise diagnostics plot (Internal)
#' @description
#' Shared back-end for the rank-selection functions (\code{nmfkc.rank},
#' \code{nmfkc.net.rank}, \code{nmfkc.signed.rank}, \code{nmfae.rank},
#' \code{nmfae.signed.rank}).  From a pre-built \code{criteria} data
#' frame it determines the recommended rank and draws a concise
#' three-criterion plot, using only \code{r.squared}, the effective-rank
#' utilization \code{effective.rank.ratio}, and the cross-validation
#' error \code{sigma.ecv}.  Any other columns present (e.g.\ \code{ARI},
#' \code{silhouette}, \code{CPCC}, \code{dist.cor} from \code{nmfkc.rank})
#' are kept in the returned table but \strong{not plotted}.  All three
#' criteria are drawn as lines with points and rank-number labels.  Two
#' carry a highlighted "Best" marker that drives the recommended rank:
#' \itemize{
#'   \item \code{r.squared}: elbow (kneedle) -- \dQuote{Best (Elbow)}.
#'   \item \code{sigma.ecv}: minimum CV error -- \dQuote{Best (Min)}.
#' }
#' The green line is the \code{effective.rank.index} (not the raw
#' \code{eff.rank / Q}): \code{(eff.rank - E) / (Q - E)} clamped to
#' \eqn{[0, 1]}, where \code{E = exp(H_Q - 1)} is the broken-stick
#' expected effective rank (\code{H_Q} = the \code{Q}-th harmonic
#' number).  This anchors 0 at the random null and 1 at perfect
#' evenness, removing the small-rank inflation of \code{eff.rank / Q}.
#' It is shown for context only and carries \strong{no} best marker: it
#' is a factor-utilization diagnostic (most even relative to the null),
#' not a predictive rank optimum -- the recommended rank is driven by
#' the cross-validation minimum and the R-squared elbow.
#' @param criteria Data frame with at least \code{rank}, \code{r.squared},
#'   \code{effective.rank.ratio}, and (optionally) \code{sigma.ecv}.
#' @param plot Logical; draw the diagnostics plot.
#' @param main Plot title.
#' @return A list with \code{rank.best} (ECV minimum if available, else
#'   the R-squared elbow) and \code{criteria} (the input data frame).
#' @keywords internal
#' @noRd
.rank.finish <- function(criteria, plot = TRUE,
                         main = "Rank Selection Diagnostics") {
  base::message("Note: sample-clustering quality (silhouette / CPCC / ",
                "dist.cor) is not part of rank selection; compute it from a ",
                "list of fits with nmf.cluster.criteria().  See ?nmf.cluster.criteria")
  rk <- criteria$rank
  nq <- base::length(rk)

  has_ecv <- !base::is.null(criteria$sigma.ecv) &&
             base::any(base::is.finite(criteria$sigma.ecv))
  rank.best.ecv <- if (has_ecv)
    rk[base::which.min(criteria$sigma.ecv)] else NA

  rank.best.r2 <- NA
  if (nq > 2 && !base::is.null(criteria$r.squared)) {
    x <- base::seq_len(nq); y <- criteria$r.squared
    yr <- base::max(y) - base::min(y); xr <- base::max(x) - base::min(x)
    yn <- if (yr > 0) (y - base::min(y)) / yr else base::rep(0.5, nq)
    xn <- if (xr > 0) (x - base::min(x)) / xr else base::rep(0.5, nq)
    x1 <- xn[1]; y1 <- yn[1]; x2 <- xn[nq]; y2 <- yn[nq]
    den <- base::sqrt((y2 - y1)^2 + (x2 - x1)^2)
    d <- if (den > 0)
      base::abs((y2 - y1) * xn - (x2 - x1) * yn + x2 * y1 - y2 * x1) / den
      else base::numeric(nq)
    rank.best.r2 <- rk[base::which.max(d)]
  }

  ## Broken-stick correction of the effective-rank utilization.
  ## Expected effective rank under a random (uniform-Dirichlet) split of
  ## the variance among Q factors is exp(H_Q - 1), H_Q = sum_{j<=Q} 1/j.
  ## The [0,1] index anchors 0 at this null and 1 at perfect evenness
  ## (eff.rank = Q): (eff.rank - expected) / (Q - expected), clamped.
  ## This removes the small-Q inflation of eff.rank / Q, so its maximum
  ## is a meaningful rank.
  if (!base::is.null(criteria$effective.rank)) {
    Hq <- base::vapply(rk, function(Q) base::sum(1 / base::seq_len(Q)),
                       base::numeric(1))
    e_null <- base::exp(Hq - 1)
    idx <- (criteria$effective.rank - e_null) / (rk - e_null)
    idx[!base::is.finite(idx)] <- NA_real_
    criteria$effective.rank.expected <- e_null
    criteria$effective.rank.index <- base::pmin(base::pmax(idx, 0), 1)
  }

  has_idx <- !base::is.null(criteria$effective.rank.index) &&
             base::any(base::is.finite(criteria$effective.rank.index))
  rank.best.idx <- if (has_idx)
    rk[base::which.max(criteria$effective.rank.index)] else NA

  rank.final <- if (!base::is.na(rank.best.ecv)) rank.best.ecv else rank.best.r2

  out <- base::list(rank.best = rank.final, criteria = criteria,
                    best = base::list(ecv = rank.best.ecv, r2 = rank.best.r2,
                                      eff.idx = rank.best.idx),
                    main = main)
  base::class(out) <- "nmf.rank"
  if (plot) graphics::plot(out)
  base::invisible(out)
}


#' @title Plot a rank-selection (nmf.rank) object
#' @description
#' Draws the concise three-criterion rank-selection plot for an object
#' returned by \code{\link{nmfkc.rank}} (or \code{nmfkc.net.rank},
#' \code{nmfkc.signed.rank}, \code{nmfae.rank}, \code{nmfae.signed.rank}):
#' \code{r.squared} (red) and the effective-rank utilization
#' \code{eff.rank} (green) on the left \eqn{[0, 1]} axis, and the
#' cross-validation error \code{sigma.ecv} (blue) on the right axis, each
#' with points, rank-number labels and a highlighted best marker.
#' @param x An object of class \code{"nmf.rank"}.
#' @param main Plot title (defaults to the title stored in \code{x}).
#' @param xlab,ylab Axis labels.
#' @param lwd Line width of the criterion curves.
#' @param ... Further arguments passed to the initial
#'   \code{\link[graphics]{plot}}.
#' @return \code{x}, invisibly.
#' @seealso \code{\link{nmfkc.rank}}
#' @export
plot.nmf.rank <- function(x, main = NULL, xlab = "Rank (Q)",
                          ylab = "R-squared / eff.rank (0-1)", lwd = 3, ...) {
  if (base::is.null(main)) main <- x$main
  criteria <- x$criteria
  rk <- criteria$rank
  rank.best.ecv <- x$best$ecv; rank.best.r2 <- x$best$r2
  rank.best.idx <- x$best$eff.idx
  has_ecv <- !base::is.null(criteria$sigma.ecv) &&
             base::any(base::is.finite(criteria$sigma.ecv))
  has_idx <- !base::is.null(criteria$effective.rank.index) &&
             base::any(base::is.finite(criteria$effective.rank.index))

  old_par <- graphics::par(mar = c(5, 4, 4, 5) + 0.1)
  base::on.exit(graphics::par(old_par))
  decorate <- function(yv, col, best_rank, best_lab, numpos) {
    graphics::points(rk, yv, pch = 16, col = col, cex = 0.8)
    graphics::text(rk, yv, rk, pos = numpos, col = col, cex = 0.8)
    if (!base::is.na(best_rank)) {
      j <- base::which(rk == best_rank)
      graphics::points(best_rank, yv[j], pch = 16, col = col, cex = 1.6)
      graphics::text(best_rank, yv[j], best_lab, pos = 4, col = col, cex = 0.8)
    }
  }
  leg_txt <- "r.squared"; leg_col <- 2

  graphics::plot(rk, criteria$r.squared, type = "l", col = 2, lwd = lwd,
                 xlab = xlab, ylab = ylab, ylim = c(0, 1), main = main, ...)
  for (q in rk) graphics::abline(v = q, col = "gray90", lwd = 0.5)
  decorate(criteria$r.squared, 2, rank.best.r2, "Best (Elbow)", 3)

  if (has_idx) {
    ## Broken-stick-corrected effective-rank index in [0, 1], shown for
    ## context only: line + points + numbers, NO best marker (it is a
    ## utilization diagnostic, not a predictive rank optimum).
    graphics::lines(rk, criteria$effective.rank.index, col = "forestgreen", lwd = lwd)
    decorate(criteria$effective.rank.index, "forestgreen", NA, "", 1)
    leg_txt <- c(leg_txt, "eff.rank"); leg_col <- c(leg_col, "forestgreen")
  }

  if (has_ecv) {
    graphics::par(new = TRUE)
    graphics::plot(rk, criteria$sigma.ecv, type = "l", col = "blue", lwd = lwd,
                   axes = FALSE, xlab = "", ylab = "")
    decorate(criteria$sigma.ecv, "blue", rank.best.ecv, "Best (Min)", 3)
    graphics::axis(side = 4, col = "blue", col.axis = "blue")
    graphics::mtext("ECV Sigma (RMSE)", side = 4, line = 3, col = "blue")
    leg_txt <- c(leg_txt, "sigma.ecv"); leg_col <- c(leg_col, "blue")
  }

  graphics::legend("right", legend = leg_txt, col = leg_col,
                   lty = 1, lwd = 2, bg = "white", cex = 0.7)
  base::invisible(x)
}


#' @title Print method for rank-selection (nmf.rank) objects
#' @param x An object of class \code{"nmf.rank"}.
#' @param ... Passed to the criteria table's \code{print}.
#' @return \code{x}, invisibly.
#' @export
print.nmf.rank <- function(x, ...) {
  fmt <- function(v) if (base::is.null(v) || base::is.na(v)) "NA" else base::as.character(v)
  base::cat(base::sprintf("Rank selection over ranks %s\n",
                          base::paste(x$criteria$rank, collapse = ", ")))
  base::cat(base::sprintf("  recommended rank (rank.best): %s\n", fmt(x$rank.best)))
  base::cat(base::sprintf("  best by:  ECV min = %s | R-squared elbow = %s\n",
                          fmt(x$best$ecv), fmt(x$best$r2)))
  base::cat("\nCriteria:\n")
  base::print(x$criteria, row.names = FALSE, ...)
  base::invisible(x)
}


#' @title Parse formula and prepare Y and A matrices
#' @description
#' Internal function to handle formula input, parse variables, and generate
#' the transposed Y and A matrices (features/covariates in rows, samples in columns)
#' as required by the core \code{nmfkc} function. Supports 'data' mode
#' (variable names and dot notation) and direct expression evaluation mode.
#' @param formula A formula object.
#' @param data Optional data frame or environment.
#' @return A list containing the prepared matrices: \code{Y} (P x N) and \code{A} (R x N or NULL).
#' @keywords internal
#' @noRd
.nmfkc_parse_formula <- function(formula, data) {

  # Helper function to abbreviate column messages (shows max_show columns)
  .abbreviate_msg <- function(cols, prefix, max_show = 5) {
    total_len <- base::length(cols)
    if (total_len > max_show) {
      msg <- base::paste0(base::paste(cols[1:max_show], collapse = ", "),
                          base::sprintf("... (Total: %d)", total_len))
    } else {
      msg <- base::paste(cols, collapse = ", ")
    }
    base::message(base::paste(prefix, "created using columns:", msg))
  }

  # Capture the formula object and set the environment
  f <- formula
  f_env <- if (base::missing(data)) base::environment(f) else base::environment()

  # Extract the expressions for Y and A
  Y_expr <- f[[2]]
  A_expr <- f[[3]]

  # --- Input Mode Branching ---

  if (!base::missing(data)) {
    # MODE 1: data provided (Variable names and dot notation)
    data <- base::as.data.frame(data)
    all_cols <- base::names(data)

    # Check if A_expr is structurally present in the formula object.
    A_is_structurally_present <- base::length(f) >= 3

    # Extract Y and A expressions as clean strings using deparse
    Y_expr_str <- base::paste(base::deparse(Y_expr), collapse = " ")

    A_expr_str <- if (A_is_structurally_present) {
      base::paste(base::deparse(A_expr), collapse = " ")
    } else {
      NA # Use simple NA for missing structural part
    }

    # Determine dot notation and missing status based on the safe string A_expr_str
    Y_is_dot <- (Y_expr_str == ".")
    A_is_missing <- base::is.na(A_expr_str)
    A_is_dot <- (!A_is_missing && A_expr_str == ".")

    # --- Check for explicit A omission symbols (0 or -1) ---
    A_is_explicitly_omitted <- (!A_is_missing &&
                                  (A_expr_str == "0" || A_expr_str == "-1" ||
                                     A_expr_str == "0 + ." || A_expr_str == "-1 + ." ||
                                     A_expr_str == ". + 0" || A_expr_str == ". + -1"))

    # Update A_is_missing status if explicitly omitted
    A_is_missing <- A_is_missing || A_is_explicitly_omitted

    Y_cols <- NULL
    A_cols <- NULL

    # Helper to clean and split variable names from expression string
    .extract_cols <- function(expr_str) {
      # Split by '+', '~', ' ' and remove empty elements. This handles "A1 + A2" structure.
      cols <- base::unlist(base::strsplit(expr_str, "[ +~]", fixed = FALSE))
      cols <- cols[cols != ""]
      # Remove explicit omission symbols if they were part of the split result
      cols <- cols[!(cols %in% base::c("0", "-1"))]
      return(cols)
    }

    if (!Y_is_dot) { Y_cols <- .extract_cols(Y_expr_str) }

    # A_cols is only extracted if A is NOT missing and NOT explicitly omitted (and not dot)
    if (!A_is_missing && !A_is_dot) {
      A_cols <- .extract_cols(A_expr_str)
    }

    # Error Check: Both sides cannot be '.'
    if (Y_is_dot && A_is_dot) {
      base::stop("Formula error: '.' cannot be used on both the left (Y) and right (A) sides simultaneously when 'data' is provided.")
    }

    used_cols <- base::unique(base::c(Y_cols, A_cols))
    remaining_cols <- base::setdiff(all_cols, used_cols)

    # Assign dot notation variables
    if (Y_is_dot) {
      Y_cols <- remaining_cols
      if (base::length(Y_cols) == 0) { base::stop("Formula error: '.' for Y resulted in no remaining variables.") }
      .abbreviate_msg(Y_cols, "Y")
    } else if (A_is_dot) {
      A_cols <- remaining_cols
      if (base::length(A_cols) == 0) { base::stop("Formula error: '.' for A resulted in no remaining variables.") }
      # NOTE: Message output for A is suppressed here to avoid duplication
      #       and is handled in the A_mat creation block below.
    }

    # Validate column existence
    missing_Y <- base::setdiff(Y_cols, all_cols)
    if (base::length(missing_Y) > 0) {
      base::stop(base::paste0("Formula error: Y columns not found in data: ",
                               base::paste(missing_Y, collapse = ", ")))
    }
    if (!A_is_missing) {
      missing_A <- base::setdiff(A_cols, all_cols)
      if (base::length(missing_A) > 0) {
        base::stop(base::paste0("Formula error: A columns not found in data: ",
                                 base::paste(missing_A, collapse = ", ")))
      }
    }

    # Matrix Creation
    Y_mat <- data[, Y_cols, drop = FALSE]

    # --- A Matrix Finalization and Message Output ---
    if (A_is_missing) { # Catches structural omission, NA, or explicit 0/-1
      A_mat <- NULL
      base::message("A (covariate matrix) is omitted. Performing standard NMF (Y ~ X B).")
    } else {
      # This block now runs if A is explicitly defined with variables or is dot notation
      A_mat <- data[, A_cols, drop = FALSE]
      .abbreviate_msg(A_cols, "A") # Message output for A is performed here ONCE
    }

  } else {
    # MODE 2: data omitted (Direct matrix expression evaluation)
    Y_cols <- NULL
    A_cols <- NULL

    if (base::is.symbol(Y_expr) && base::as.character(Y_expr) == ".") {
      base::stop("Formula error: '.' is not supported for direct matrix evaluation mode (without 'data' argument).")
    }

    Y_mat <- base::tryCatch({ base::as.matrix(base::eval(Y_expr, envir = f_env)) },
                            error = function(e) { base::stop(base::paste("Error evaluating Y expression:", base::conditionMessage(e))) })

    A_mat <- base::tryCatch({
      # Check if A_expr exists (length >= 3) or is explicitly 0/-1
      A_is_structurally_present <- base::length(f) >= 3
      if (!A_is_structurally_present || (base::is.numeric(A_expr) && A_expr == 0) || (base::is.numeric(A_expr) && A_expr == -1)) { NULL
      } else { base::as.matrix(base::eval(A_expr, envir = f_env)) }
    }, error = function(e) { base::stop(base::paste("Error evaluating A expression:", base::conditionMessage(e))) })

    if (base::is.null(A_mat)) {
      base::message("A (covariate matrix) is omitted. Performing standard NMF (Y ~ X B).")
    }
    # NOTE: No column abbreviation message needed for Mode 2 as matrices are evaluated directly.
  }

  # Transpose: R rows=samples -> nmfkc columns=samples (P x N or R x N)
  # NOTE: R data frames/matrices are typically samples x variables. nmfkc requires variables x samples.
  Y <- base::t(Y_mat)

  if (!base::is.null(A_mat)) {
    A <- base::t(A_mat)
    if (base::ncol(Y) != base::ncol(A)) {
      base::stop(base::paste0("Dimension error: Number of columns (samples) in Y (", base::ncol(Y), ") must match number of columns (samples) in A (", base::ncol(A), ")."))
    }
  } else {
    A <- NULL
  }

  return(base::list(Y = Y, A = A, Y_cols = Y_cols, A_cols = A_cols))
}


#' Resolve formula input to Y/A matrices with metadata
#'
#' Internal helper that detects formula input, parses it, and returns
#' the Y/A matrices along with formula metadata for downstream use.
#' The \code{data_missing} flag solves the \code{missing(data)} propagation
#' problem in R (cannot check \code{missing()} through nested calls).
#'
#' @param Y A formula or matrix.
#' @param A A matrix or NULL.
#' @param data_missing Logical; TRUE if \code{data} was not supplied by the caller.
#' @param data_value The value of \code{data} (or NULL if missing).
#' @return A list with \code{Y}, \code{A}, and \code{formula.meta} (NULL if not formula mode).
#' @keywords internal
#' @noRd
.nmfkc_resolve_formula <- function(Y, A, data_missing, data_value) {
  if (!base::inherits(Y, "formula")) {
    return(base::list(Y = Y, A = A, formula.meta = NULL))
  }
  if (data_missing) {
    data_list <- .nmfkc_parse_formula(formula = Y)
  } else {
    data_list <- .nmfkc_parse_formula(formula = Y, data = data_value)
  }
  formula.meta <- base::list(
    formula = Y,
    Y_cols  = data_list$Y_cols,
    A_cols  = data_list$A_cols
  )
  base::list(Y = data_list$Y, A = data_list$A, formula.meta = formula.meta)
}










#' @title Optimize NMF with kernel covariates (Full Support for Missing Values)
#' @description
#' \code{nmfkc} fits a nonnegative matrix factorization with kernel covariates
#' under the tri-factorization model \eqn{Y \approx X C A = X B}.
#'
#' This function supports two major input modes:
#' 1. **Matrix Mode (Existing)**: \code{nmfkc(Y=matrix, A=matrix, ...)}
#' 2. **Formula Mode (New)**: \code{nmfkc(formula=Y_vars ~ A_vars, data=df, rank=Q, ...)}
#'
#' The rank of the basis matrix can be specified using either the \code{rank} argument
#' (preferred for formula mode) or the hidden \code{Q} argument (for backward compatibility).
#'
#' @param Y Observation matrix (P x N), OR a formula object for Formula Mode.
#'   In Formula Mode, use \code{Y1 + Y2 ~ A1 + A2} with \code{data}, or
#'   \code{Y_matrix ~ A_matrix} for direct matrix evaluation.
#'   Supports dot notation (\code{. ~ A1 + A2}) when \code{data} is supplied.
#' @param A Covariate matrix. Default is \code{NULL} (no covariates).
#'   Ignored when \code{Y} is a formula.
#' @param rank Integer. The rank of the basis matrix \eqn{X} (Q). Preferred over \code{Q}.
#' @param data Optional. A data frame from which variables in the formula should be taken.
#' @param epsilon Positive convergence tolerance.
#' @param maxit Maximum number of iterations.
#' @param verbose Logical. If \code{TRUE} (default), prints matrix dimensions and elapsed time.
#' @param ... Additional arguments passed for fine-tuning regularization, initialization, constraints,
#'   and output control. This includes the backward-compatible arguments \code{Q} and \code{method}.
#'   \itemize{
#'     \item \code{Y.weights}: Optional weight matrix (P x N) or vector
#'       (length N) with non-negative entries, analogous to the \code{weights}
#'       argument of \code{\link[stats]{lm}}.  When supplied, the objective
#'       becomes \eqn{\sum W_{ij} \, (Y_{ij} - (XB)_{ij})^2}
#'       (i.e.\ \strong{linear} in \eqn{W}; \code{lm()}-style weighted
#'       least squares).  Logical matrices (\code{TRUE} / \code{FALSE})
#'       are also accepted and coerced to 1 / 0.  The primary use case is
#'       missing-value masking for ECV / CV, where \eqn{W_{ij} \in \{0, 1\}}
#'       (\code{FALSE} / \code{TRUE}) indicates held-out vs.\ used elements;
#'       real-valued weights for observation-level importance weighting are
#'       also supported.  Default \code{NULL}: if \code{Y} contains \code{NA}
#'       a binary mask is auto-constructed (0 for \code{NA}, 1 elsewhere);
#'       otherwise no weighting.
#'     \item \code{X.L2.ortho}: Nonnegative penalty parameter for the orthogonality of \eqn{X} (default: 0).
#'       It minimizes the off-diagonal elements of the Gram matrix \eqn{X^\top X}, reducing the correlation
#'       between basis vectors (conceptually minimizing \eqn{\| X^\top X - \mathrm{diag}(X^\top X) \|_F^2}).
#'       (Formerly \code{lambda.ortho}).
#'     \item \code{X.L2.smooth}: Nonnegative penalty parameter for row-smoothness of
#'       \eqn{X} (default: 0). Adds \eqn{\lambda\,\mathrm{tr}(X^\top L X)} with \eqn{L}
#'       the path-graph Laplacian over the \eqn{P} rows, i.e.\ it penalizes squared
#'       differences between adjacent rows \eqn{\sum_q\sum_{j\ge 2}(x_{jq}-x_{j-1,q})^2},
#'       yielding gently-varying (smooth) bases. Integrates with the multiplicative
#'       updates (non-negativity and monotone descent preserved). Useful when the rows
#'       of \eqn{Y} have a natural order (e.g.\ time points).
#'     \item \code{C.L1}: Nonnegative penalty parameter for L1 regularization on \eqn{C} (default: 0).
#'       Promotes **sparsity in the parameter matrix** \eqn{\Theta} (variable
#'       selection over basis-covariate links). (Formerly \code{lambda}).
#'     \item \code{Q}: Backward-compatible name for the rank of the basis matrix (Q).
#'     \item \code{method}: Objective function: Euclidean distance \code{"EU"} (default) or Kullback–Leibler divergence \code{"KL"}.
#'     \item \code{X.restriction}: Constraint for columns of \eqn{X}. Options: \code{"colSums"} (default), \code{"colSqSums"}, \code{"totalSum"}, \code{"none"}, or \code{"fixed"}.
#'       \code{"none"} applies no normalization to \eqn{X} after each update, allowing it to absorb the scale freely.
#'     \item \code{X.init}: Method for initializing the basis matrix \eqn{X}. Options: \code{"kmeans"} (default), \code{"kmeansar"}, \code{"kmeans++"}, \code{"runif"}, \code{"nndsvd"}, or a user-specified matrix. \code{"kmeansar"} applies \eqn{k}-means initialization and then fills zero entries with \code{Uniform(0, mean(Y)/100)}, analogous to NNDSVDar. \code{"kmeans++"} seeds the \eqn{k}-means centres by \eqn{D^2} weighting (Arthur & Vassilvitskii, 2007) before Lloyd refinement, giving a more careful, stable initialization (\code{nstart} is not used in this case).
#'     \item \code{nstart}: Number of random starts for initialization of \eqn{X} (default: 1).
#'       Used by \code{kmeans} (when \code{X.init = "kmeans"} or \code{"kmeansar"}) and by the
#'       multi-start evaluation (when \code{X.init = "runif"}).
#'     \item \code{seed}: Integer seed for reproducibility (default: 123).
#'     \item \code{C.init}: Optional numeric matrix giving the initial value of the parameter matrix \eqn{C}
#'       (i.e., \eqn{\Theta}). If \code{A} is \code{NULL}, \code{C} has dimension \eqn{Q \times N} (equivalently \eqn{B});
#'       otherwise, \code{C} has dimension \eqn{Q \times K} where \eqn{K = nrow(A)}. Default initializes all entries to 1.
#'     \item \code{Y.symmetric}: \strong{Removed.} Symmetric NMF
#'       (\eqn{Y \approx X X^\top} or \eqn{X C X^\top}) has moved to the
#'       dedicated \code{\link{nmfkc.net}} function (types \code{"tri"},
#'       \code{"bi"}, \code{"signed"}), which uses the correct
#'       Frobenius bilateral-gradient updates.  Passing \code{Y.symmetric}
#'       to \code{nmfkc()} now stops with a message pointing to
#'       \code{nmfkc.net()}.
#'     \item \code{prefix}: Prefix for column names of \eqn{X} and row names of \eqn{B} (default: "Basis").
#'     \item \code{print.trace}: Logical. If \code{TRUE}, prints progress every 10 iterations (default: \code{FALSE}).
#'     \item \code{print.dims}: Deprecated. Use \code{verbose} instead.
#'     \item \code{detail}: Level of post-fit criterion computation.
#'       \code{"full"} computes all criteria including silhouette, CPCC, dist.cor;
#'       \code{"fast"} skips expensive distance-based criteria;
#'       \code{"minimal"} returns only information criteria.
#'       Default is \code{"full"}. For backward compatibility,
#'       \code{save.time = TRUE} maps to \code{"fast"} and
#'       \code{save.memory = TRUE} maps to \code{"minimal"}.
#'   }
#' @return A list with components:
#' \item{call}{The matched call, as captured by `match.call()`.}
#' \item{dims}{A character string summarizing the matrix dimensions of the model.}
#' \item{runtime}{A character string summarizing the computation time.}
#' \item{X}{Basis matrix. Column normalization depends on \code{X.restriction}.}
#' \item{B}{Coefficient matrix \eqn{B = C A}.}
#' \item{XB}{Fitted values for \eqn{Y}.}
#' \item{C}{Parameter matrix.}
#' \item{B.prob}{Soft-clustering probabilities derived from columns of \eqn{B}.}
#' \item{B.cluster}{Hard-clustering labels (argmax over \eqn{B.prob} for each column).}
#' \item{X.prob}{Row-wise soft-clustering probabilities derived from \eqn{X}.}
#' \item{X.cluster}{Hard-clustering labels (argmax over \eqn{X.prob} for each row).}
#' \item{A.attr}{List of attributes of the input covariate matrix \code{A}, containing metadata like lag order and intercept status if created by \code{nmfkc.ar} or \code{nmfkc.kernel}.}
#' \item{formula.meta}{If fitted via Formula Mode, a list with \code{formula}, \code{Y_cols}, and \code{A_cols}; otherwise \code{NULL}.}
#' \item{objfunc}{Final objective value.}
#' \item{objfunc.iter}{Objective values by iteration.}
#' \item{r.squared}{\eqn{R^2 = \mathrm{cor}(Y, XB)^2} (Pearson; scale-invariant; \eqn{[0,1]}).}
#' \item{r.squared.uncentered}{Uncentered \eqn{R^2 = 1 - \|Y - XB\|_F^2 / \|Y\|_F^2} (baseline = zero matrix; natural for non-negative factorizations without an intercept).}
#' \item{r.squared.centered}{Row-mean centered \eqn{R^2 = 1 - \|Y - XB\|_F^2 / \|Y - \bar Y_{p\cdot}\|_F^2}, the multivariate regression \eqn{R^2}.}
#' \item{method}{Character string indicating the optimization method used (\code{"EU"} or \code{"KL"}).}
#' \item{n.missing}{Number of missing (or zero-weighted) elements in \eqn{Y}.}
#' \item{n.total}{Total number of elements in \eqn{Y}.}
#' \item{rank}{The rank \eqn{Q} used in the factorization.}
#' \item{sigma}{The residual standard error, representing the typical deviation of the observed values \eqn{Y} from the fitted values \eqn{X B}.}
#' \item{mae}{Mean Absolute Error between \eqn{Y} and \eqn{X B}.}
#' \item{criterion}{A list of selection criteria: \code{silhouette} (mean silhouette width of the hard clustering, computed in the original data space \code{dist(t(Y))} with the per-sample labels), \code{CPCC} (cophenetic correlation of a hierarchical clustering of the coefficient distances \code{dist(t(B))}), \code{dist.cor} (correlation between original-data and coefficient distances), \code{B.prob.max.mean} (clustering crispness: mean dominant-cluster membership, in \eqn{[1/Q, 1]}; meaningful at fixed \eqn{Q} as a confidence check before using \code{B.cluster} as hard labels), and \code{effective.rank}.  The last is the \strong{effective rank}: \eqn{\exp} of the Shannon entropy of the explained-variance distribution \eqn{p_k = \mathrm{var}(B_{k\cdot}) / \sum_j \mathrm{var}(B_{j\cdot})}.  By the trace identity \eqn{\sum_k \mathrm{var}(B_{k\cdot}) = \mathrm{tr}(\mathrm{Cov}(B))}, \eqn{p_k} is the exact fraction of the total coefficient variance carried by factor \eqn{k}, so the entropy measures how that variance is spread across factors.  It ranges in \eqn{[1, Q]} (1 when one factor carries all the variance, \eqn{Q} when all contribute equally) and counts the number of latent factors that actively shape across-sample variation.  This is the PCA-style explained-variance / effective-dimensionality measure and reuses the \eqn{\exp(\mathrm{entropy})} functional form of Roy & Vetterli (2007).}
#' @seealso \code{\link{nmfkc.cv}}, \code{\link{nmfkc.rank}}, \code{\link{nmfkc.kernel}}, \code{\link{nmfkc.ar}}, \code{\link{predict.nmfkc}}
#' @export
#' @references
#' Satoh, K. (2024). Applying Non-negative Matrix Factorization with Covariates
#'   to the Longitudinal Data as Growth Curve Model. arXiv:2403.05359.
#'   \url{https://arxiv.org/abs/2403.05359}
#'
#' Satoh, K. (2025). Applying non-negative matrix factorization with covariates
#'   to multivariate time series data as a vector autoregression model.
#'   \emph{Japanese Journal of Statistics and Data Science}. arXiv:2501.17446.
#'   \doi{10.1007/s42081-025-00314-0}
#'
#' Satoh, K. (2025). Applying non-negative matrix factorization with covariates
#'   to label matrix for classification. arXiv:2510.10375.
#'   \url{https://arxiv.org/abs/2510.10375}
#'
#' Ding, C., Li, T., Peng, W., & Park, H. (2006). Orthogonal Nonnegative Matrix
#'   Tri-Factorizations for Clustering.
#'   In \emph{Proc. 12th ACM SIGKDD} (pp. 126--135).
#'   \doi{10.1145/1150402.1150420}
#'
#' Roy, O., & Vetterli, M. (2007). The effective rank: A measure of
#'   effective dimensionality. In \emph{15th European Signal Processing
#'   Conference (EUSIPCO)} (pp. 606--610).
#' @examples
#' # Example 1. Matrix Mode (Existing)
#' X <- cbind(c(1,0,1),c(0,1,0))
#' B <- cbind(c(1,0),c(0,1),c(1,1))
#' Y <- X %*% B
#' rownames(Y) <- paste0("P",1:nrow(Y))
#' colnames(Y) <- paste0("N",1:ncol(Y))
#' print(X); print(B); print(Y)
#' res <- nmfkc(Y,rank=2,epsilon=1e-6)
#' res$X
#' res$B
#'
#' # Example 2. Formula Mode
#' set.seed(1)
#' dummy_data <- data.frame(Y1=rpois(10,5), Y2=rpois(10,10),
#'                          A1=abs(rnorm(10,5)), A2=abs(rnorm(10,3)))
#' res_f <- nmfkc(Y1 + Y2 ~ A1 + A2, data=dummy_data, rank=2)
#'
#' # For symmetric NMF (Y approximated by X X^T or X C X^T),
#' # use \code{\link{nmfkc.net}()} instead.
#'
nmfkc <- function(Y, A=NULL, rank=NULL, data, epsilon=1e-4, maxit=5000, verbose=TRUE, ...){
  # A small constant for numerical stability to prevent division by zero and log(0).
  .eps <- 1e-10

  extra_args <- base::list(...)

  # --- 1. Parameter Extraction ---
  Q_hidden <- if (!base::is.null(extra_args$Q)) extra_args$Q else NULL
  Q_val <- if (!base::is.null(rank)) rank else if (!base::is.null(Q_hidden)) Q_hidden else 2
  Q <- Q_val

  C.L1 <- if (!base::is.null(extra_args$C.L1)) extra_args$C.L1 else 0
  X.L2.ortho <- if (!base::is.null(extra_args$X.L2.ortho)) extra_args$X.L2.ortho else 0
  ## Row-smoothness penalty on X: lambda * tr(X' L X) with L the path-graph
  ## Laplacian over the P rows (adjacent rows only). Encourages gently-varying
  ## bases. Default 0 = off (exact current behaviour).
  X.L2.smooth <- if (!base::is.null(extra_args$X.L2.smooth)) extra_args$X.L2.smooth else 0

  if (C.L1 == 0 && !base::is.null(extra_args$lambda)) C.L1 <- extra_args$lambda
  if (X.L2.ortho == 0 && !base::is.null(extra_args$lambda.ortho)) X.L2.ortho <- extra_args$lambda.ortho

  method <- if (!base::is.null(extra_args$method)) extra_args$method else "EU"
  X.restriction <- if (!base::is.null(extra_args$X.restriction)) extra_args$X.restriction else "colSums"
  X.init <- if (!base::is.null(extra_args$X.init)) extra_args$X.init else "kmeans"
  nstart <- if (!base::is.null(extra_args$nstart)) extra_args$nstart else 1
  seed <- if (!base::is.null(extra_args$seed)) extra_args$seed else 123
  C.init <- if (!is.null(extra_args$C.init)) extra_args$C.init else NULL
  ## Symmetric NMF (Y ~ X X^T or X C X^T) has moved out of nmfkc() into
  ## the dedicated nmfkc.net() function (Frobenius bilateral-gradient
  ## updates; types "tri", "bi", "signed").  The old Y.symmetric option
  ## was deprecated in v0.7.x and is removed here: passing it now stops
  ## with a redirect message rather than silently running plain NMF.
  if (!base::is.null(extra_args$Y.symmetric) &&
      !base::identical(base::as.character(extra_args$Y.symmetric), "none")) {
    ys <- base::as.character(extra_args$Y.symmetric)[1]
    ys_type <- if (ys %in% c("bi", "tri")) ys else "tri"
    base::stop(
      "`Y.symmetric` is no longer supported in nmfkc().\n",
      "Symmetric NMF has moved to nmfkc.net(). Use:\n",
      "    nmfkc.net(Y, rank, type = \"", ys_type, "\")\n",
      "which implements the correct Frobenius bilateral-gradient updates.\n",
      "See help(nmfkc.net) for the types \"tri\", \"bi\", and \"signed\".",
      call. = FALSE
    )
  }

  prefix <- if (!base::is.null(extra_args$prefix)) extra_args$prefix else "Basis"
  print.trace <- if (!base::is.null(extra_args$print.trace)) extra_args$print.trace else FALSE
  print.dims <- verbose
  if (!base::is.null(extra_args$print.dims)) print.dims <- extra_args$print.dims  # backward compat
  save.time <- if (!base::is.null(extra_args$save.time)) extra_args$save.time else FALSE
  save.memory <- if (!base::is.null(extra_args$save.memory)) extra_args$save.memory else FALSE
  detail <- if (!base::is.null(extra_args$detail)) extra_args$detail else NULL
  if (base::is.null(detail)) {
    # backward compatibility: derive detail from save.time / save.memory
    if (save.memory) detail <- "minimal"
    else if (save.time) detail <- "fast"
    else detail <- "full"
  }
  detail <- base::match.arg(detail, base::c("full", "fast", "minimal"))

  Y.weights <- if (!base::is.null(extra_args$Y.weights)) extra_args$Y.weights else NULL

  # --- 2. Input Data Preparation ---
  formula.meta <- NULL
  if (base::inherits(Y, "formula")) {
    data_missing <- base::missing(data)
    resolved <- .nmfkc_resolve_formula(Y, A, data_missing, if (!data_missing) data else NULL)
    Y <- resolved$Y
    A <- resolved$A
    formula.meta <- resolved$formula.meta
  } else {
    if(base::is.vector(Y)) Y <- base::matrix(Y,nrow=1)
    if(!base::is.matrix(Y)) Y <- base::as.matrix(Y)
  }

  # --- Input Validation (after formula dispatch) ---
  if(!base::is.null(A)) {
    if(any(is.na(A))) base::stop("Covariate matrix A contains NAs. Please impute or remove them.")
    if(base::min(A, na.rm=TRUE)<0) base::stop("The matrix A should be non-negative.")
  }
  if(base::min(Y, na.rm=TRUE)<0) base::stop("The matrix Y should be non-negative.")

  # === Weights Handling ===
  # 1. Vector Expansion (Column-wise weights)
  if (!is.null(Y.weights) && is.vector(Y.weights)) {
    if (length(Y.weights) == ncol(Y)) {
      # Expand vector to matrix: each row gets the same weight vector
      Y.weights <- matrix(Y.weights, nrow = nrow(Y), ncol = ncol(Y), byrow = TRUE)
    } else if (length(Y.weights) == 1) {
      Y.weights <- matrix(Y.weights, nrow = nrow(Y), ncol = ncol(Y))
    } else {
      stop("Length of Y.weights vector must match ncol(Y) (or be 1).")
    }
  }

  # 2. Check Dimensions & Handle NAs
  if (is.null(Y.weights)) {
    if (any(is.na(Y))) {
      Y.weights <- matrix(1, nrow=nrow(Y), ncol=ncol(Y))
      Y.weights[is.na(Y)] <- 0
      Y[is.na(Y)] <- 0
      if(print.dims) message("Notice: Missing values (NA) in Y were treated as weights=0.")
    } else {
      Y.weights <- matrix(1, nrow = nrow(Y), ncol = ncol(Y))
    }
  } else {
    if (!is.matrix(Y.weights)) Y.weights <- as.matrix(Y.weights)
    if (!all(dim(Y.weights) == dim(Y))) stop("Dimension mismatch between Y and Y.weights.")
    Y.weights[is.na(Y.weights)] <- 0
    Y[is.na(Y)] <- 0
    Y[Y.weights == 0] <- 0
  }

  # --- 3. Algorithm Setup ---
  X.restriction <- base::match.arg(X.restriction, base::c("colSums", "colSqSums", "totalSum", "none", "fixed"))
  xnorm <- base::switch(X.restriction,
                        colSums   = function(X) base::sweep(X, 2, base::colSums(X), "/"),
                        colSqSums = function(X) base::sweep(X, 2, base::sqrt(base::colSums(X^2)), "/"),
                        totalSum  = function(X) X / base::sum(X),
                        none  = function(X) X,
                        fixed = function(X) X
  )

  if(base::is.null(A)){
    dims <- base::sprintf("Y(%d,%d)~X(%d,%d)B(%d,%d)",
                          base::nrow(Y),base::ncol(Y),base::nrow(Y),Q,Q,base::ncol(Y))
  }else{
    dims <- base::sprintf("Y(%d,%d)~X(%d,%d)C(%d,%d)A(%d,%d)=XB(%d,%d)",
                          base::nrow(Y),base::ncol(Y),base::nrow(Y),Q,Q,base::nrow(A),base::nrow(A),base::ncol(Y),Q,base::ncol(Y))
  }
  if(print.dims) base::message(base::paste0(dims,"..."),appendLF=FALSE)
  start.time <- base::Sys.time()

  # Initialize X
  is.X.scalar <- FALSE
  if(nrow(Y)>=2){
    X <- .nmfkc_init_X(Y, Q, X.init, Y.weights, seed, nstart, maxit, .eps)
  }else{
    X <- matrix(data=1,nrow=1,ncol=1)
    is.X.scalar <- TRUE
  }
  X <- xnorm(X)

  # [FIX: Initialization of tX]
  # Initialize tX here so it exists even if the X update loop is skipped (e.g., scalar X)
  tX <- t(X)

  if(is.null(A)){
    if(is.null(C.init)) C <- matrix(1, nrow=Q, ncol=ncol(Y)) else C <- C.init
  }else{
    if(is.null(C.init)) C <- matrix(1, nrow=Q, ncol=nrow(A)) else C <- C.init
  }
  hasA <- !is.null(A)

  ones_QN <- matrix(1, nrow=Q, ncol=ncol(Y))
  if(hasA) {
    At <- t(A)
  }

  epsilon.iter <- Inf
  objfunc.iter <- 0*(1:maxit)
  i_end <- NULL

  # --- 4. Main Loop (Weighted) ---
  for(i in 1:maxit){
    if(is.null(A)) B <- C else B <- C %*% A
    XB <- X %*% B
    if(print.trace && i %% 10==0) message(paste0(format(Sys.time(), "%X")," ",i,"..."))

    if(method=="EU"){
      if(!is.X.scalar && X.restriction!="fixed"){
        num_X <- (Y.weights * Y) %*% t(B)
        den_X <- (Y.weights * XB) %*% t(B)
        if (X.L2.ortho > 0) {
          XtX <- crossprod(X); diag(XtX) <- 0
          den_X <- den_X + X.L2.ortho * (X %*% XtX)
        }
        if (X.L2.smooth > 0 && nrow(X) >= 2) {
          ## path-graph Laplacian over rows: grad = lambda (D X - W X)
          Pr <- nrow(X)
          WX <- X * 0
          WX[-Pr, ] <- WX[-Pr, ] + X[-1, , drop = FALSE]   # + lower neighbour
          WX[-1, ]  <- WX[-1, ]  + X[-Pr, , drop = FALSE]  # + upper neighbour
          degX <- c(1, rep(2, Pr - 2), 1) * X
          num_X <- num_X + X.L2.smooth * WX
          den_X <- den_X + X.L2.smooth * degX
        }
        update_ratio <- num_X / (den_X + .eps)
        X <- X * update_ratio
        X <- xnorm(X)
        tX <- t(X)
      }
      if(is.null(A)) {
        num_C <- tX %*% (Y.weights * Y)
        den_C <- tX %*% (Y.weights * XB)
        if (C.L1 != 0) den_C <- den_C + (C.L1/2) * ones_QN
        C <- C * (num_C / (den_C + .eps))
      } else {
        num_C <- tX %*% (Y.weights * Y) %*% At
        den_C <- tX %*% (Y.weights * XB) %*% At
        if (C.L1 != 0) den_C <- den_C + (C.L1/2) * matrix(1, nrow=Q, ncol=nrow(A))
        C <- C * (num_C / (den_C + .eps))
      }
      ## lm()-style weighted least squares: L = sum(W * (Y - XB)^2).
      ## The multiplicative updates (num/den above) already carry W linearly,
      ## so reporting the linear-W objective here makes MU target and reported
      ## loss consistent for any non-negative W.  For binary W in {0,1}
      ## (the standard ECV / CV / NA-mask case) this is identical to
      ## sum((W*(Y-XB))^2) since W = W^2.
      obj <- sum(Y.weights * (Y - XB)^2)

    }else{ # KL
      if(!is.X.scalar && X.restriction!="fixed"){
        ratio <- Y.weights * (Y / (XB + .eps))
        num_X <- ratio %*% t(B)
        den_X <- Y.weights %*% t(B)
        if (X.L2.ortho > 0) {
          XtX <- crossprod(X); diag(XtX) <- 0
          den_X <- den_X + X.L2.ortho * (X %*% XtX)
        }
        if (X.L2.smooth > 0 && nrow(X) >= 2) {
          ## path-graph Laplacian over rows: grad = lambda (D X - W X)
          Pr <- nrow(X)
          WX <- X * 0
          WX[-Pr, ] <- WX[-Pr, ] + X[-1, , drop = FALSE]
          WX[-1, ]  <- WX[-1, ]  + X[-Pr, , drop = FALSE]
          degX <- c(1, rep(2, Pr - 2), 1) * X
          num_X <- num_X + X.L2.smooth * WX
          den_X <- den_X + X.L2.smooth * degX
        }
        update_ratio <- num_X / (den_X + .eps)
        X <- X * update_ratio
        X <- xnorm(X)
        tX <- t(X)
      }
      if(is.null(A)) {
        ratio <- Y.weights * (Y / (XB + .eps))
        num_C <- tX %*% ratio
        den_C <- tX %*% Y.weights
        if (C.L1 != 0) den_C <- den_C + C.L1 * ones_QN
        C <- C * (num_C / (den_C + .eps))
      } else {
        ratio <- Y.weights * (Y / (XB + .eps))
        num_C <- tX %*% ratio %*% At
        den_C <- tX %*% Y.weights %*% At
        if (C.L1 != 0) den_C <- den_C + C.L1 * matrix(1, nrow=Q, ncol=nrow(A))
        C <- C * (num_C / (den_C + .eps))
      }
      term1 <- - (Y.weights * Y) * log(XB + .eps)
      term2 <- Y.weights * XB
      obj <- sum(term1 + term2)
    }

    if (C.L1 != 0) obj <- obj + C.L1 * sum(C)
    if (X.L2.ortho != 0) {
      XtX <- crossprod(X); diag(XtX) <- 0
      obj <- obj + (X.L2.ortho / 2) * sum(XtX^2)
    }
    if (X.L2.smooth != 0 && nrow(X) >= 2) {
      ## (lambda/2) tr(X' L X) = (lambda/2) sum of squared adjacent-row diffs
      obj <- obj + (X.L2.smooth / 2) * sum((X[-1, , drop = FALSE] -
                                            X[-nrow(X), , drop = FALSE])^2)
    }
    objfunc.iter[i] <- obj

    if(i>=10){
      #epsilon.iter <- abs(objfunc.iter[i]-objfunc.iter[i-1])/(abs(objfunc.iter[i])+0.1)
      epsilon.iter <- abs(objfunc.iter[i]-objfunc.iter[i-1]) / pmax(abs(objfunc.iter[i]), 1)
      if(epsilon.iter <= abs(epsilon)){ i_end <- i; break }
    }
  }

  if(is.null(A)) B <- C else B <- C %*% A
  XB <- X %*% B

  if(method=="EU"){
    ## lm()-style weighted least squares; see note above near line 1446.
    objfunc <- sum(Y.weights * (Y - XB)^2)
  } else {
    term1 <- - (Y.weights * Y) * log(XB + .eps)
    term2 <- Y.weights * XB
    objfunc <- sum(term1 + term2)
  }

  if(!is.null(i_end)){ objfunc.iter <- objfunc.iter[10:i_end]
  } else if (i >= 10){ objfunc.iter <- objfunc.iter[10:i]
  } else { objfunc.iter <- objfunc.iter[1:i] }

  if(ncol(X) > 1 && X.restriction != "fixed"){
    index <- order(matrix(1:nrow(X)/nrow(X),nrow=1) %*% X)
    X <- X[,index,drop=FALSE]; B <- B[index,,drop=FALSE]
    C <- C[index,,drop=FALSE]
  }
  rownames(C) <- paste0(prefix,1:nrow(C))
  if (!is.null(A)) {
    colnames(C) <- if (!is.null(rownames(A))) rownames(A) else paste0("Cov", 1:ncol(C))
  }
  rownames(X) <- rownames(Y); colnames(X) <- paste0(prefix,1:ncol(X))
  rownames(B) <- paste0(prefix,1:nrow(B)); colnames(B) <- colnames(Y)

  # --- Compute criteria via nmfkc.criterion ---
  crit_result <- nmfkc.criterion(
    base::list(X = X, B = B, C = C, XB = XB, method = method, A.attr = if(!base::is.null(A)) base::attributes(A) else NULL),
    Y, detail = detail, Y.weights = Y.weights, X.restriction = X.restriction
  )
  r2          <- crit_result$r.squared
  r2.uncentered     <- crit_result$r.squared.uncentered
  r2.centered <- crit_result$r.squared.centered
  sigma       <- crit_result$sigma
  mae         <- crit_result$mae
  B.prob    <- crit_result$B.prob
  B.cluster <- crit_result$B.cluster
  X.prob    <- crit_result$X.prob
  X.cluster <- crit_result$X.cluster
  if (detail == "minimal") XB <- NA

  if(epsilon.iter > abs(epsilon)) warning(paste0("maximum iterations (",maxit,") reached..."))
  end.time <- Sys.time()
  ## runtime stored as numeric seconds (house style, matches nmfkc.net/.signed);
  ## formatted for display in print()/summary().
  diff.time.sec <- as.numeric(difftime(end.time, start.time, units = "sec"))
  if(print.dims) message(paste0(round(diff.time.sec, 1), "sec"))

  n.missing <- sum(Y.weights == 0)
  n.total <- prod(dim(Y))
  A.attr <- NULL
  if (!is.null(A)) A.attr <- attributes(A)

  result <- list(
    call      = match.call(),
    dims      = dims,
    runtime   = diff.time.sec,
    method    = method,
    X         = X,
    B         = B,
    XB        = XB,
    C         = C,
    B.prob    = B.prob,
    B.cluster = B.cluster,
    X.prob    = X.prob,
    X.cluster = X.cluster,
    A.attr    = A.attr,
    formula.meta = formula.meta,
    n.missing = n.missing,
    n.total   = n.total,
    rank      = Q,
    objfunc   = objfunc,
    objfunc.iter = objfunc.iter,
    r.squared          = r2,
    r.squared.uncentered     = r2.uncentered,
    r.squared.centered = r2.centered,
    sigma     = sigma,
    mae = mae,
    criterion = crit_result$criterion
  )
  class(result) <- c("nmfkc", "nmf")
  return(result)
}















#' @title Plot method for objects of class \code{nmfkc}
#' @description
#' \code{plot.nmfkc} produces a diagnostic plot for the return value of
#' \code{nmfkc}, showing the objective function across iterations.
#'
#' @param x An object of class \code{nmfkc}, i.e., the return value of \code{nmfkc}.
#' @param ... Additional arguments passed to the base \code{\link{plot}} function.
#' @return Called for its side effect (a plot). Returns \code{NULL} invisibly.
#' @examples
#' Y <- matrix(cars$dist, nrow = 1)
#' A <- rbind(1, cars$speed)
#' result <- nmfkc(Y, A, rank = 1)
#' plot(result)
#'
#' @seealso \code{\link{nmfkc}}, \code{\link{summary.nmfkc}}
#' @export
plot.nmfkc <- function(x,...){
  extra_args <- list(...)
  args <- list(x = x$objfunc.iter)
  if(is.null(extra_args$main)) args$main <- paste0("r.squared=",round(x$r.squared,3))
  if(is.null(extra_args$xlab)) args$xlab <- "iter"
  if(is.null(extra_args$ylab)) args$ylab <- "objfunc"
  all_args <- c(args, extra_args)
  do.call("plot",all_args)
}




#' @title Summary method for objects of class \code{nmfkc}
#' @description
#' Produces a summary of an \code{nmfkc} object, including matrix dimensions,
#' runtime, fit statistics, and diagnostics.
#'
#' @param object An object of class \code{nmfkc}, i.e., the return value of \code{nmfkc}.
#' @param ... Additional arguments (currently unused).
#' @return An object of class \code{summary.nmfkc}, containing summary statistics.
#' @examples
#' Y <- matrix(cars$dist, nrow = 1)
#' A <- rbind(1, cars$speed)
#' result <- nmfkc(Y, A, rank = 1)
#' summary(result)
#'
#' @seealso \code{\link{nmfkc}}, \code{\link{nmfkc.inference}}, \code{\link{plot.nmfkc}}
#' @export
summary.nmfkc <- function(object, ...) {
  ans <- list()
  ans$call <- object$call
  ans$dims <- object$dims
  ans$rank <- object$rank
  ans$runtime <- object$runtime

  # Missing values
  if(!is.null(object$n.missing) && !is.null(object$n.total)){
    ans$n.missing <- object$n.missing
    ans$prop.missing <- object$n.missing / object$n.total * 100
  } else {
    ans$n.missing <- NULL
  }

  ans$formula.meta <- object$formula.meta
  ans$method <- object$method
  ans$iter <- length(object$objfunc.iter)
  ans$objfunc <- object$objfunc
  ans$r.squared          <- object$r.squared
  ans$r.squared.uncentered     <- object$r.squared.uncentered
  ans$r.squared.centered <- object$r.squared.centered
  ans$sigma <- object$sigma
  ans$mae <- object$mae
  if(!is.null(object$criterion)){
    ans$effective.rank <- object$criterion$effective.rank
  } else {
    ans$effective.rank <- NULL
  }
  ans$rank <- object$rank

  # --- Diagnostics (Sparsity & Clustering Quality) ---

  # 1. Basis (X)
  if (!is.null(object$X) && is.matrix(object$X)) {
    # Sparsity: Proportion of elements close to zero (< 1e-4)
    ans$X.sparsity <- mean(object$X < 1e-4)
  }

  # 2. Probabilities (B.prob)
  if (!is.null(object$B.prob)){
    # Sparsity
    ans$B.prob.sparsity <- mean(object$B.prob < 1e-4)
    ans$B.prob.max.mean <- object$criterion$B.prob.max.mean
  }

  class(ans) <- "summary.nmfkc"
  return(ans)
}

#' @title Print method for \code{summary.nmfkc} objects
#' @description
#' Prints a formatted summary of an \code{nmfkc} model fit.
#'
#' @param x An object of class \code{summary.nmfkc}.
#' @param digits Minimum number of significant digits to be used.
#' @param ... Additional arguments (currently unused).
#' @return Called for its side effect (printing). Returns \code{x} invisibly.
#' @examples
#' Y <- matrix(cars$dist, nrow = 1)
#' A <- rbind(1, cars$speed)
#' result <- nmfkc(Y, A, rank = 1)
#' print(summary(result))
#'
#' @export
print.summary.nmfkc <- function(x, digits = max(3L, getOption("digits") - 3L), ...) {
  cat("\nCall:\n", paste(deparse(x$call), sep = "\n", collapse = "\n"), "\n\n", sep = "")

  if (!is.null(x$formula.meta)) {
    cat("Formula:    ", deparse(x$formula.meta$formula), "\n")
  }
  cat("Dimensions:", x$dims, "\n")
  if(!is.null(x$rank)) cat("Rank (Q):   ", x$rank, "\n")
  cat("Runtime:    ",
      if (is.numeric(x$runtime)) sprintf("%.1fsec", x$runtime) else x$runtime, "\n")
  if (!is.null(x$method)) cat("Method:     ", x$method, "\n")
  cat("Iterations: ", x$iter, "\n")

  if (!is.null(x$n.missing)) {
    cat("Missing:    ", x$n.missing,
        sprintf("(%.1f%%)", x$prop.missing), "\n")
  }

  .print.fit.statistics(x, digits = digits)

  .print.structure.diagnostics(
    sparsity  = c("Basis (X)" = x$X.sparsity, "Coef (B)" = x$B.prob.sparsity),
    crispness = x$B.prob.max.mean)
  cat("\n")
  invisible(x)
}














#' @title Normalize a matrix to the range \eqn{[0,1]}
#' @description
#' \code{nmfkc.normalize} rescales the values of a matrix to lie between 0 and 1
#' using the column-wise minimum and maximum values of a reference matrix.
#'
#' @param x A numeric matrix (or vector) to be normalized.
#' @param ref A reference matrix from which the column-wise minima and maxima are taken.
#'   Default is \code{x}.
#'
#' @return A matrix of the same dimensions as \code{x}, with each column rescaled to the \eqn{[0,1]} range.
#' @seealso \code{\link{nmfkc.denormalize}}
#' @export
#' @examples
#' # Example.
#' x <- nmfkc.normalize(iris[,-5])
#' apply(x,2,range)
nmfkc.normalize <- function(x,ref=x){
  if(is.vector(x)){
    x <- matrix(x,ncol=1)
    ref <- matrix(ref,ncol=1)
  }
  r <- apply(ref,2,range)
  denom <- r[2, ] - r[1, ]
  denom[denom == 0] <- 1   # leave zero-width columns unchanged
  y <- sweep(x, 2, r[1, ], FUN = "-")
  y <- sweep(y, 2, denom,   FUN = "/")
  return(y)
}




#' @title Denormalize a matrix from \eqn{[0,1]} back to its original scale
#' @description
#' \code{nmfkc.denormalize} rescales a matrix with values in \eqn{[0,1]} back to its
#' original scale using the column-wise minima and maxima of a reference matrix.
#'
#' @param x A numeric matrix (or vector) with values in \eqn{[0,1]} to be denormalized.
#' @param ref A reference matrix used to obtain the original column-wise minima
#'   and maxima. Must have the same number of columns as \code{x}.
#'
#' @return A numeric matrix with values transformed back to the original scale.
#' @seealso \code{\link{nmfkc.normalize}}
#' @export
#' @examples
#' x <- nmfkc.normalize(iris[, -5])
#' x_recovered <- nmfkc.denormalize(x, iris[, -5])
#' apply(x_recovered - iris[, -5], 2, max)
nmfkc.denormalize <- function(x, ref=x) {
  if (is.vector(x)) {
    x <- matrix(x, ncol = 1)
    ref <- matrix(ref, ncol = 1)
  }
  r <- apply(ref, 2, range)
  y <- sweep(x, 2, r[2, ] - r[1, ], FUN = "*")
  y <- sweep(y, 2, r[1, ], FUN = "+")
  return(y)
}




#' @title Create a class (one-hot) matrix from a categorical vector
#' @description
#' \code{nmfkc.class} converts a categorical or factor vector into a class matrix
#' (one-hot encoded representation), where each row corresponds to a category
#' and each column corresponds to an observation.
#'
#' @param x A categorical vector or a factor.
#'
#' @return A binary matrix with one row per unique category and one column per observation. Each column has exactly one entry equal to 1, indicating the category of the observation.
#' @seealso \code{\link{nmfkc}}
#' @export
#' @examples
#' # Example.
#' Y <- nmfkc.class(iris$Species)
#' Y[,1:6]
nmfkc.class <- function(x){
  if(!is.factor(x)) x <- as.factor(x)
  lev <- levels(x)
  X <- outer(lev, x, "==")
  mode(X) <- "numeric"
  rownames(X) <- lev
  if(!is.null(names(x))) colnames(X) <- names(x) else colnames(X) <- seq_along(x)
  X
}





#' @title Prediction method for objects of class \code{nmfkc}
#' @description
#' \code{predict.nmfkc} generates predictions from an object of class \code{nmfkc},
#' either using the fitted covariates or a new covariate matrix.
#'
#' When the model was fitted using a formula (Formula Mode), a \code{newdata}
#' data frame can be supplied instead of \code{newA}; the covariate matrix is
#' then constructed automatically from the stored formula metadata.
#'
#' @param object An object of class \code{nmfkc}, i.e., the return value of \code{nmfkc}.
#' @param newA Optional. A new covariate matrix to be used for prediction.
#' @param newdata Optional data frame. Only available when the model was fitted
#'   using a formula. Covariate columns are extracted automatically using the
#'   stored formula metadata. If both \code{newdata} and \code{newA} are
#'   supplied, \code{newdata} takes precedence (with a warning).
#' @param type Type of prediction to return. Options are "response" (fitted values matrix),
#'   "prob" (soft-clustering probabilities), or "class" (hard-clustering labels based on row names of X).
#' @param ... Further arguments passed to or from other methods.
#' @return Depending on \code{type}: a numeric matrix (\code{"response"} or \code{"prob"})
#'   or a character vector of class labels (\code{"class"}).
#' @examples
#' # Prediction with newA
#' Y <- matrix(cars$dist, nrow = 1)
#' A <- rbind(1, cars$speed)
#' result <- nmfkc(Y, A, rank = 1)
#' newA <- rbind(1, c(10, 20, 30))
#' predict(result, newA = newA)
#'
#' @seealso \code{\link{nmfkc}}, \code{\link{nmfkc.cv}}
#' @export
predict.nmfkc <- function(object, newA = NULL, newdata = NULL, type = "response", ...) {
  x <- object
  .eps <- 1e-10

  # --- newdata handling (Formula Mode) ---
  if (!is.null(newdata)) {
    if (is.null(x$formula.meta)) {
      stop("'newdata' can only be used when the model was fitted with a formula.")
    }
    if (!is.null(newA)) {
      warning("Both 'newdata' and 'newA' supplied; 'newdata' takes precedence.")
    }
    A_cols <- x$formula.meta$A_cols
    if (is.null(A_cols)) {
      stop("Formula metadata has no A columns. Model was fitted without covariates (standard NMF).")
    }
    missing_cols <- setdiff(A_cols, names(newdata))
    if (length(missing_cols) > 0) {
      stop(paste0("'newdata' is missing required columns: ", paste(missing_cols, collapse = ", ")))
    }
    newdata <- as.data.frame(newdata)
    newA <- t(as.matrix(newdata[, A_cols, drop = FALSE]))
  }

  if(is.null(newA)){
    if(type=="response"){
      result <- x$X %*% x$B
    }else{
      XB.prob <- x$X %*% x$B.prob
      if(type=="prob"){
        result <- XB.prob
      }else{
        result <- rownames(x$X)[apply(XB.prob,2,which.max)]
      }
    }
  }else{
    B <- x$C %*% newA
    if(type=="response"){
      result <- x$X %*% B
    }else{
      B.prob <- sweep(B, 2, colSums(B) + .eps, "/")
      XB.prob <- x$X %*% B.prob
      if(type=="prob"){
        result <- XB.prob
      }else{
        result <- rownames(x$X)[apply(XB.prob,2,which.max)]
      }
    }
  }
  return(result)
}








#' @title Perform k-fold cross-validation for NMF with kernel covariates
#'
#' @description
#' \code{nmfkc.cv} performs k-fold cross-validation for the tri-factorization model
#' \eqn{Y \approx X C A = X B}, where
#' \itemize{
#'   \item \eqn{Y(P,N)} is the observation matrix,
#'   \item \eqn{A(R,N)} is the covariate (or kernel) matrix,
#'   \item \eqn{X(P,Q)} is the basis matrix,
#'   \item \eqn{C(Q,R)} is the parameter matrix, and
#'   \item \eqn{B(Q,N)} is the coefficient matrix (\eqn{B = C A}).
#' }
#' Given \eqn{Y} (and optionally \eqn{A}), \eqn{X} and \eqn{C} are fitted on each
#' training split and predictive performance is evaluated on the held-out split.
#'
#' @param Y Observation matrix, or a formula (see \code{\link{nmfkc}} for Formula Mode).
#' @param A Covariate matrix. If \code{NULL}, the identity matrix is used.
#'   Ignored when \code{Y} is a formula.
#' @param rank Rank of the basis matrix \eqn{X}. Default is 2.
#' @param data A data frame (required when \code{Y} is a formula with column names).
#' @param ... Additional arguments controlling CV and the internal \code{\link{nmfkc}} call:
#'   \describe{
#'     \item{\code{Y.weights}}{Non-negative weight matrix or vector
#'       (\code{lm()}-style: loss \eqn{\sum W \, r^2}).  Binary
#'       \code{{0,1}} masks (\code{TRUE} / \code{FALSE} also accepted)
#'       are the typical ECV usage -- 0/\code{FALSE} excludes an element.
#'       See \code{\link{nmfkc}} for full details.}
#'     \item{\code{div}}{Number of folds (\eqn{k}); default: \code{5}.}
#'     \item{\code{seed}}{Integer seed for reproducible partitioning; default: \code{123}.}
#'     \item{\code{shuffle}}{Logical. If \code{TRUE} (default), randomly shuffles samples (standard CV);
#'       if \code{FALSE}, splits sequentially (block CV; recommended for time series).}
#'     \item{\code{Q}}{(Deprecated) Alias for \code{rank}.}
#'     \item{\emph{Arguments passed to} \code{\link{nmfkc}}}{e.g., \code{C.L1}, \code{epsilon},
#'       \code{maxit}, \code{method} (\code{"EU"} or \code{"KL"}), \code{X.restriction}, \code{X.init}, etc.}
#'   }
#'
#' @return A list with components:
#'   \describe{
#'     \item{\code{objfunc}}{Mean loss per valid entry over all folds (MSE for \code{method="EU"}).}
#'     \item{\code{sigma}}{Residual standard error (RMSE). Available only if \code{method="EU"}; on the same scale as \code{Y}.}
#'     \item{\code{objfunc.block}}{Loss for each fold.}
#'     \item{\code{block}}{Vector of fold indices (1, …, \code{div}) assigned to each column of \eqn{Y}.}
#'   }
#'
#' @seealso \code{\link{nmfkc}}, \code{\link{nmfkc.kernel.beta.cv}}, \code{\link{nmfkc.ar.degree.cv}}
#'
#' @examples
#' # Example 1 (with explicit covariates):
#' Y <- matrix(cars$dist, nrow = 1)
#' A <- rbind(1, cars$speed)
#' res <- nmfkc.cv(Y, A, rank = 1)
#' res$objfunc
#'
#' # Example 2 (kernel A and beta sweep):
#' Y <- matrix(cars$dist, nrow = 1)
#' U <- matrix(c(5, 10, 15, 20, 25), nrow = 1)
#' V <- matrix(cars$speed, nrow = 1)
#' betas <- 25:35/1000
#' obj <- numeric(length(betas))
#' for (i in seq_along(betas)) {
#'   A <- nmfkc.kernel(U, V, beta = betas[i])
#'   obj[i] <- nmfkc.cv(Y, A, rank = 1, nfolds = 10)$objfunc
#' }
#' betas[which.min(obj)]
#'
#' @export

nmfkc.cv <- function(Y, A=NULL, rank=2, data, ...){
  # --- Formula Mode ---
  if (base::inherits(Y, "formula")) {
    resolved <- .nmfkc_resolve_formula(Y, A, base::missing(data), if (!base::missing(data)) data else NULL)
    Y <- resolved$Y
    A <- resolved$A
  }

  # A small constant for numerical stability to prevent division by zero and log(0).
  .eps <- 1e-10

  extra_args <- list(...)
  if (!is.null(extra_args$Q)) rank <- extra_args$Q
  Q <- rank

  # fold count: accept nfolds (house-style name) as well as div
  div <- if (!is.null(extra_args$nfolds)) extra_args$nfolds
         else if (!is.null(extra_args$div)) extra_args$div else 5
  seed <- if (!is.null(extra_args$seed)) extra_args$seed else 123
  shuffle <- if (!is.null(extra_args$shuffle)) extra_args$shuffle else TRUE

  epsilon <- if (!is.null(extra_args$epsilon)) extra_args$epsilon else 1e-4
  maxit   <- if (!is.null(extra_args$maxit))   extra_args$maxit   else 5000
  method  <- if (!is.null(extra_args$method))  extra_args$method  else "EU"
  Y.weights <- if (!is.null(extra_args$Y.weights)) extra_args$Y.weights else NULL

  if(!is.matrix(Y)) Y <- as.matrix(Y)
  P <- nrow(Y)
  N <- ncol(Y)

  # === Weights Preparation (Same as nmfkc) ===
  # Expand vector to matrix for easier slicing
  if (!is.null(Y.weights) && is.vector(Y.weights)) {
    if (length(Y.weights) == N) {
      Y.weights <- matrix(Y.weights, nrow = P, ncol = N, byrow = TRUE)
    } else if (length(Y.weights) == 1) {
      Y.weights <- matrix(Y.weights, nrow = P, ncol = N)
    } else {
      stop("Length of Y.weights vector must match ncol(Y) (or be 1).")
    }
  }
  # Handle NAs
  if (is.null(Y.weights)) {
    if (any(is.na(Y))) {
      Y.weights <- matrix(1, nrow=P, ncol=N)
      Y.weights[is.na(Y)] <- 0
      Y[is.na(Y)] <- 0
    } else {
      Y.weights <- matrix(1, nrow=P, ncol=N) # Full 1s for easier slicing logic
    }
  } else {
    if (!is.matrix(Y.weights)) Y.weights <- as.matrix(Y.weights)
    Y.weights[is.na(Y.weights)] <- 0
    Y[is.na(Y)] <- 0
    Y <- Y * Y.weights
  }

  # --- Helper: Weighted Optimization of B given fixed X and Y_test ---
  optimize.B.from.Y <- function(result, Y_test, W_test, epsilon, maxit, method){
    X <- result$X
    # Initialize C (which acts as B here)
    C <- matrix(1, nrow=ncol(X), ncol=ncol(Y_test))

    oldSum <- 0
    epsilon.iter <- Inf

    for(l in 1:maxit){
      B <- C
      XB <- X %*% B

      if(method=="EU"){
        # Weighted Update for B (EU)
        # Num: X^T (W * Y)
        num <- t(X) %*% (W_test * Y_test)
        # Den: X^T (W * XB)
        den <- t(X) %*% (W_test * XB)

        C <- C * ( num / (den + .eps) )

      }else{ # KL
        # Weighted Update for B (KL)
        # Num: X^T (W * (Y/XB))
        ratio <- W_test * (Y_test / (XB + .eps))
        num <- t(X) %*% ratio
        # Den: X^T W
        den <- t(X) %*% W_test

        C <- C * ( num / (den + .eps) )
      }

      newSum <- sum(C)
      if(l>=10){
        #epsilon.iter <- abs(newSum-oldSum)/(abs(newSum)+0.1)
        epsilon.iter <- abs(newSum-oldSum) / pmax(abs(newSum), 1)
        if(epsilon.iter <= abs(epsilon)) break
      }
      oldSum <- sum(C)
    }

    B <- C
    XB <- X %*% B
    # Note: colnames handling omitted for speed in CV
    return(list(B=B, XB=XB))
  }

  is.identity.matrix <- function(A, tol = .Machine$double.eps) {
    if (nrow(A) != ncol(A)) return(FALSE)
    isTRUE(all.equal(A, diag(nrow(A)), tolerance = tol))
  }

  if(is.null(A)){
    is_identity <- TRUE
    is_symmetric.matrix <- FALSE
  }else{
    is_identity <- is.identity.matrix(A)
    is_symmetric.matrix <- isSymmetric(A, tol=.Machine$double.eps)
    A.function <- attr(A, "function.name")
    is_kernel_matrix <- !is.null(A.function) && A.function == "nmfkc.kernel"
  }

  # Create Folds
  remainder <- N %% div
  division <- N %/% div
  block <- 0*(1:N)

  if(shuffle){
    set.seed(seed)
    perm_index <- sample(1:N, N, replace=FALSE)
  } else {
    perm_index <- 1:N
  }

  processed_count <- 0
  for(i in 1:(div-1)){
    plus <- ifelse(i <= remainder, 1, 0)
    chunk_size <- division + plus
    target_indices <- perm_index[(processed_count + 1):(processed_count + chunk_size)]
    block[target_indices] <- i
    processed_count <- processed_count + chunk_size
  }
  target_indices <- perm_index[(processed_count + 1):N]
  block[target_indices] <- div

  objfunc.block <- numeric(div)
  total_valid_obs <- 0 # Denominator for weighted mean error

  for(j in 1:div){
    # Slice Y and Weights
    # Train
    Y_train <- Y[,block!=j, drop=FALSE]
    W_train <- Y.weights[,block!=j, drop=FALSE]

    # Test
    Y_test <- Y[,block==j, drop=FALSE]
    W_test <- Y.weights[,block==j, drop=FALSE]

    # Slice A
    if(is_identity){
      A_train <- NULL
    }else{
      if(is_symmetric.matrix && is_kernel_matrix){
        A_train <- A[block!=j,block!=j, drop=FALSE]
        A_test  <- A[block!=j,block==j, drop=FALSE]
      }else{
        A_train <- A[,block!=j, drop=FALSE]
        A_test  <- A[,block==j, drop=FALSE]
      }
    }

    # Run NMF on Training set (passing W_train)
    nmfkc_args <- c(
      list(...),
      list(Y = Y_train, A = A_train, Q = Q, Y.weights = W_train, # Pass weights!
           seed=NULL, print.trace = FALSE, print.dims = FALSE,
           save.time = TRUE, save.memory = TRUE)
    )
    nmfkc_args$shuffle <- NULL

    # Suppress messages from inner nmfkc calls
    res_j <- suppressMessages(do.call("nmfkc", nmfkc_args))

    # Predict on Test set
    if(is_identity){
      # Standard NMF: Optimize B for test set using weights
      resj <- optimize.B.from.Y(res_j, Y_test, W_test, epsilon, maxit, method)
      XB_test <- resj$XB
    }else{
      # Covariate NMF: Predict using A_test
      # XB = X * C * A_test
      XB_test <- res_j$X %*% res_j$C %*% A_test
    }

    # Evaluate Error (Weighted)
    if(method=="EU"){
      resid <- W_test * (Y_test - XB_test)
      objfunc.block[j] <- sum(resid^2)
    }else{
      term1 <- - (W_test * Y_test) * log(XB_test + .eps)
      term2 <- W_test * XB_test
      objfunc.block[j] <- sum(term1 + term2)
    }

    total_valid_obs <- total_valid_obs + sum(W_test > 0)
  }

  # Mean error per valid observation
  # (Avoid division by zero if all weights are 0, though unlikely)
  objfunc <- sum(objfunc.block) / max(total_valid_obs, 1)

  # Calculate RMSE for EU (This corresponds to 'sigma')
  sigma <- if(method == "EU") sqrt(objfunc) else NA

  out <- list(objfunc = objfunc, sigma = sigma, rank = Q, nfolds = div,
              objfunc.block = objfunc.block, block = block)
  class(out) <- "nmfkc.cv"
  out
}





#' @title Perform Element-wise Cross-Validation (Wold's CV)
#' @description
#' \code{nmfkc.ecv} performs k-fold cross-validation by randomly holding out
#' individual elements of the data matrix (element-wise), assigning them a
#' weight of 0 via `Y.weights`, and evaluating the reconstruction error on
#' those held-out elements.
#'
#' This method (also known as Wold's CV) is theoretically robust for determining
#' the optimal rank (Q) in NMF. This function supports vector input for `Q`,
#' allowing simultaneous evaluation of multiple ranks on the same folds.
#'
#' For symmetric (network) data use \code{\link{nmfkc.net.ecv}}, which
#' creates upper-triangle folds to prevent information leakage through
#' the symmetric entries \eqn{Y_{ij} = Y_{ji}}.  Passing the old
#' \code{Y.symmetric} argument here is no longer supported and stops
#' with a redirect message.
#'
#' @param Y Observation matrix, or a formula (see \code{\link{nmfkc}} for Formula Mode).
#' @param A Covariate matrix. Ignored when \code{Y} is a formula.
#' @param rank Vector of ranks to evaluate (e.g., 1:5). For backward compatibility,
#'   \code{Q} is accepted via \code{...}.
#' @param data A data frame (required when \code{Y} is a formula with column names).
#' @param ... Additional arguments passed to \code{\link{nmfkc}} (e.g., \code{method="EU"}).
#'   Also accepts: \code{nfolds} (number of folds, default 5; \code{div} also accepted),
#'   \code{seed} (integer seed, default 123).
#'
#' @return A list with components:
#' \item{objfunc}{Numeric vector containing the Mean Squared Error (MSE) for each Q.}
#' \item{sigma}{Numeric vector containing the Residual Standard Error (RMSE) for each Q. Only available if method="EU".}
#' \item{objfunc.fold}{List of length equal to Q vector. Each element contains the MSE values for the k folds.}
#' \item{folds}{A list of length \code{div}, containing the linear indices of held-out elements for each fold (shared across all Q).}
#' @references
#' Wold, S. (1978).  Cross-validatory estimation of the number of
#' components in factor and principal components models.
#' \emph{Technometrics}, 20(4), 397--405.
#' \doi{10.1080/00401706.1978.10489693}
#' Owen, A. B., & Perry, P. O. (2009).  Bi-cross-validation of the SVD
#' and the nonnegative matrix factorization.  \emph{Ann. Appl. Stat.}
#' 3(2), 564--594. \doi{10.1214/08-AOAS227}  (cross-validation of the
#' NMF rank; see also \code{\link{nmfkc.bicv}}).
#' @seealso \code{\link{nmfkc}}, \code{\link{nmfkc.cv}}; other rank-selection
#'   criteria: \code{\link{nmfkc.rank}}, \code{\link{nmfkc.bicv}},
#'   \code{\link{nmfkc.consensus}}, \code{\link{nmfkc.ard}}.
#' @examples
#' # Element-wise CV to select rank
#' Y <- t(iris[1:30, 1:4])
#' res <- nmfkc.ecv(Y, rank = 1:2, nfolds = 3)
#' res$objfunc
#'
#' @export
nmfkc.ecv <- function(Y, A=NULL, rank=1:3, data, ...){
  extra_ecv <- list(...)
  if (!is.null(extra_ecv$Q)) rank <- extra_ecv$Q
  nfolds <- if (!is.null(extra_ecv$nfolds)) extra_ecv$nfolds else if (!is.null(extra_ecv$div)) extra_ecv$div else 5
  seed   <- if (!is.null(extra_ecv$seed))   extra_ecv$seed   else 123
  Q <- rank
  div <- nfolds
  # --- Formula Mode ---
  if (base::inherits(Y, "formula")) {
    resolved <- .nmfkc_resolve_formula(Y, A, base::missing(data), if (!base::missing(data)) data else NULL)
    Y <- resolved$Y
    A <- resolved$A
  }

  if(!is.matrix(Y)) Y <- as.matrix(Y)
  P <- nrow(Y)
  N <- ncol(Y)

  # --- Argument Handling ---
  extra_args <- list(...)

  # If user mistakenly passed 'rank' in ..., treat it as Q
  if (!is.null(extra_args$rank)) {
    Q <- extra_args$rank
  }

  # Symmetric element-wise CV (upper-triangle folds) has moved to
  # nmfkc.net.ecv().  Passing Y.symmetric here stops with a redirect.
  if (!is.null(extra_args$Y.symmetric) &&
      !identical(as.character(extra_args$Y.symmetric), "none")) {
    ys <- as.character(extra_args$Y.symmetric)[1]
    ys_type <- if (ys %in% c("bi", "tri")) ys else "tri"
    stop("`Y.symmetric` is no longer supported in nmfkc.ecv().\n",
         "Symmetric element-wise CV has moved to nmfkc.net.ecv(). Use:\n",
         "    nmfkc.net.ecv(Y, rank, type = \"", ys_type, "\")\n",
         "See help(nmfkc.net.ecv).", call. = FALSE)
  }

  # 1. Create Folds (shared element-wise helper)
  folds <- .ecv.make.folds(Y, div, seed)

  method <- if(!is.null(extra_args$method)) extra_args$method else "EU"

  # Clean extra args for the inner nmfkc() calls (once, outside the loop)
  nmfkc_clean_args <- extra_args
  nmfkc_clean_args$Q <- NULL
  nmfkc_clean_args$rank <- NULL
  nmfkc_clean_args$print.trace <- NULL
  nmfkc_clean_args$print.dims <- NULL
  nmfkc_clean_args$save.time <- NULL
  nmfkc_clean_args$save.memory <- NULL

  # Model-specific worker: mask fold k, refit at rank Q[i], held-out loss
  run_one <- function(i, k) {
    q_curr <- Q[i]
    test_idx <- folds[[k]]
    weights_train <- matrix(1, nrow = P, ncol = N)
    if (any(is.na(Y))) weights_train[is.na(Y)] <- 0
    weights_train[test_idx] <- 0
    nmfkc_args <- c(list(Y = Y, A = A, Q = q_curr, Y.weights = weights_train,
                         print.trace = FALSE, print.dims = FALSE,
                         save.time = TRUE), nmfkc_clean_args)
    fit <- suppressMessages(do.call("nmfkc", nmfkc_args))
    pred <- fit$XB
    if (method == "KL") {
      .eps <- 1e-10
      mean(-Y[test_idx] * log(pred[test_idx] + .eps) + pred[test_idx])
    } else {
      mean((Y[test_idx] - pred[test_idx])^2)
    }
  }

  # 2. Loop over Q via shared driver
  message(paste0("Performing Element-wise CV for Q = ", paste(Q, collapse=","), " (", div, "-fold)..."))
  cv <- .ecv.run(paste0("Q=", Q), div, run_one)
  if (method != "EU") cv$sigma[] <- NA   # sigma = RMSE only for EU loss

  out <- list(objfunc = cv$objfunc,
              sigma = cv$sigma,
              rank = Q,
              nfolds = div,
              objfunc.fold = cv$objfunc.fold,
              folds = folds)
  class(out) <- "nmfkc.ecv"
  out
}


#' @title Compute model selection criteria for a fitted nmfkc model
#' @description
#' \code{nmfkc.criterion} computes the effective rank, clustering-quality
#' measures (silhouette, CPCC, dist.cor), and the clustering-crispness
#' statistic (\code{B.prob.max.mean}) from a fitted \code{nmfkc} model.
#'
#' This function can be called on a model that was fitted with
#' \code{detail = "fast"} or \code{detail = "minimal"} to compute the
#' full set of criteria afterwards.
#'
#' @param object An object of class \code{"nmfkc"} returned by
#'   \code{\link{nmfkc}}.
#' @param Y The original observation matrix (P x N) used for fitting.
#' @param detail Character string controlling the level of computation:
#'   \code{"full"} (default) computes all criteria including silhouette,
#'   CPCC and dist.cor;
#'   \code{"fast"} skips the expensive distance-based criteria;
#'   \code{"minimal"} skips the fit and clustering statistics.
#' @param ... Additional arguments: \code{Y.weights} (non-negative
#'   weight matrix; \code{lm()}-style loss \eqn{\sum W \, r^2}; default:
#'   all ones).  See \code{\link{nmfkc}} for full details.
#'
#' @return A list with components:
#' \describe{
#'   \item{r.squared}{R-squared between Y and XB.}
#'   \item{sigma}{Residual standard deviation.}
#'   \item{mae}{Mean absolute error.}
#'   \item{B.prob}{Column-normalized coefficient matrix (soft-clustering probabilities).}
#'   \item{B.cluster}{Hard clustering labels (argmax of B.prob per column).}
#'   \item{X.prob}{Row-normalized basis matrix.}
#'   \item{X.cluster}{Hard clustering labels per row of X.}
#'   \item{criterion}{Named list: B.prob.max.mean, effective.rank,
#'     silhouette, CPCC, dist.cor.}
#' }
#'
#' @seealso \code{\link{nmfkc}}, \code{\link{nmfkc.rank}}
#' @export
#' @examples
#' Y <- t(iris[, -5])
#' res <- nmfkc(Y, rank = 3, detail = "fast")
#' crit <- nmfkc.criterion(res, Y)
#' crit$criterion$silhouette
#'
nmfkc.criterion <- function(object, Y, detail = c("full", "fast", "minimal"), ...) {
  detail <- match.arg(detail)
  extra_args <- base::list(...)

  X <- object$X
  B <- object$B
  C <- object$C
  XB <- object$XB
  method <- if (!is.null(object$method)) object$method else "EU"
  Q <- base::ncol(X)
  .eps <- 1e-10

  Y <- base::as.matrix(Y)
  P <- base::nrow(Y)
  N <- base::ncol(Y)

  # Y.weights
  Y.weights <- if (!is.null(extra_args$Y.weights)) extra_args$Y.weights
               else base::matrix(1, nrow = P, ncol = N)

  # Recompute XB if missing (detail="minimal" case)
  if (base::is.null(XB) || (base::length(XB) == 1 && base::is.na(XB))) {
    hasA <- !base::is.null(object$A.attr)
    if (hasA) {
      # Cannot recompute without A; use object$B
      XB <- X %*% B
    } else {
      XB <- X %*% B
    }
  }

  # --- Fit statistics and clustering (detail != "minimal") ---
  if (detail != "minimal") {
    valid_idx <- (Y.weights > 0)
    if (base::any(valid_idx)) {
      r2_all <- .r.squared.all(Y, XB, Y.weights = Y.weights)
      r2 <- r2_all$r.squared
      r2.uncentered <- r2_all$r.squared.uncentered
      r2.centered <- r2_all$r.squared.centered
      sigma <- stats::sd(Y[valid_idx] - XB[valid_idx])
      mae <- base::mean(base::abs(Y[valid_idx] - XB[valid_idx]))
    } else {
      r2 <- NA; r2.uncentered <- NA; r2.centered <- NA
      sigma <- NA; mae <- NA
    }

    B.prob <- base::t(base::t(B) / (base::colSums(B) + .eps))
    ## Clustering crispness: mean over samples of the dominant-cluster
    ## membership.  Range [1/Q, 1]; higher = more decisive (hard-like)
    ## soft assignment.  Meaningful at fixed Q (e.g. as a confidence
    ## check before treating B.cluster as hard labels); not used for
    ## rank selection (it is monotone in Q).
    if (Q > 1) {
      B.prob.max.mean <- base::mean(base::apply(B.prob, 2, base::max))
    } else {
      B.prob.max.mean <- 1
    }
    B.cluster <- base::apply(B.prob, 2, base::which.max)
    B.cluster[base::colSums(B.prob) == 0] <- NA
    X.prob <- X / (base::rowSums(X) + .eps)
    X.cluster <- base::apply(X.prob, 1, base::which.max)

    ## Effective rank: exp(Shannon entropy) of the explained-variance
    ## distribution p_k = var(B[k, ]) / sum_j var(B[j, ]).  We use the
    ## across-sample VARIANCE (not the standard deviation) on purpose:
    ## by the trace identity sum_k var(B[k, ]) = tr(Cov(B)) = total
    ## variance, so p_k is the exact fraction of total variance carried
    ## by factor k -- a genuine additive decomposition, which is what
    ## makes the entropy "effective number" interpretation well-posed
    ## (standard deviations are not additive, so sum_k sd_k has no such
    ## meaning).  This is the PCA-style "explained-variance entropy" /
    ## effective-dimensionality measure; it reuses the exp(entropy)
    ## functional form of Roy & Vetterli (2007).  See .effective.rank().
    ## NA at Q = 1 (single-point variance distribution -> trivially 1),
    ## consistent with silhouette / CPCC which are also NA at Q = 1.
    effective.rank <- .effective.rank(B)

    # Distance-based criteria (detail == "full" only); computed by the
    # shared .cluster.criteria() helper using the hard labels B.cluster
    # already derived above (so values are unchanged).  The same helper
    # backs the user-facing nmf.cluster.criteria().
    if (detail == "full" && !base::any(Y.weights == 0)) {
      cc <- .cluster.criteria(Y, B, labels = B.cluster)
      silhouette <- cc$silhouette
      CPCC       <- cc$CPCC
      dist.cor   <- cc$dist.cor
    } else {
      silhouette <- NA; CPCC <- NA; dist.cor <- NA
    }
  } else {
    r2 <- NA; r2.uncentered <- NA; r2.centered <- NA
    sigma <- NA; mae <- NA
    B.prob <- NA; B.cluster <- NA
    B.prob.max.mean <- NA
    X.prob <- NA; X.cluster <- NA
    silhouette <- NA; CPCC <- NA; dist.cor <- NA
    effective.rank <- NA_real_
  }

  base::list(
    r.squared          = r2,
    r.squared.uncentered     = r2.uncentered,
    r.squared.centered = r2.centered,
    sigma     = sigma,
    mae       = mae,
    B.prob    = B.prob,
    B.cluster = B.cluster,
    X.prob    = X.prob,
    X.cluster = X.cluster,
    criterion = base::list(
      B.prob.max.mean     = B.prob.max.mean,
      effective.rank      = effective.rank,
      silhouette = silhouette,
      CPCC       = CPCC,
      dist.cor   = dist.cor
    )
  )
}


#' @title Rank selection diagnostics with graphical output
#' @description
#' \code{nmfkc.rank} provides diagnostic criteria for selecting the rank (\eqn{Q})
#' in NMF with kernel covariates. Three rank-selection measures are computed
#' (R-squared, the effective rank, and the element-wise CV error), and results
#' can be visualized in a plot. Sample-clustering quality (silhouette / CPCC /
#' dist.cor) is no longer part of rank selection; use \code{\link{nmf.cluster.criteria}}
#' on a fitted model for those.
#'
#' By default (\code{save.time = FALSE}), this function also computes the
#' Element-wise Cross-Validation error (Wold's CV Sigma) using \code{\link{nmfkc.ecv}}.
#'
#' The plot explicitly marks the "BEST" rank based on two criteria:
#' \enumerate{
#'   \item **Elbow Method (Red)**: Based on the curvature of the R-squared values (always computed if Q > 2).
#'   \item **Min RMSE (Blue)**: Based on the minimum Element-wise CV Sigma (only if \code{detail="full"}).
#' }
#'
#' @param Y Observation matrix, or a formula (see \code{\link{nmfkc}} for Formula Mode).
#' @param A Covariate matrix. If \code{NULL}, the identity matrix is used.
#'   Ignored when \code{Y} is a formula.
#' @param rank A vector of candidate ranks to be evaluated.
#' @param detail \code{"full"} (default) also runs the element-wise CV
#'   (\code{sigma.ecv}); \code{"fast"} skips it (the plot then shows only
#'   r.squared and eff.rank, and the recommended rank falls back to the
#'   R-squared elbow).
#' @param plot Logical. If \code{TRUE} (default), draws a plot of the diagnostic criteria.
#' @param data A data frame (required when \code{Y} is a formula with column names).
#' @param ... Additional arguments passed to \code{\link{nmfkc}} and \code{\link{nmfkc.ecv}}.
#'   \itemize{
#'     \item \code{Q}: (Deprecated) Alias for \code{rank}.
#'     \item \code{save.time}: (Deprecated) \code{TRUE} maps to \code{detail = "fast"}.
#'   }
#'
#' @return A list containing:
#' \item{rank.best}{The estimated optimal rank. Prioritizes ECV minimum if available, otherwise R-squared Elbow.}
#' \item{criteria}{A data frame containing diagnostic metrics for each rank.
#'   The \code{effective.rank} column gives the effective rank
#'   (\eqn{\exp} of the Shannon entropy of the explained-variance
#'   distribution \eqn{p_k = \mathrm{var}(B_{k\cdot}) / \sum_j \mathrm{var}(B_{j\cdot})},
#'   in \eqn{[1, Q]}); when it plateaus well below the nominal
#'   \code{rank}, the extra factors are not carrying additional
#'   coefficient variance, which suggests an over-specified rank.
#'   The \code{effective.rank.ratio} column is \code{effective.rank /
#'   rank} in \eqn{[0, 1]} (the utilization fraction plotted as
#'   \code{eff.rank} when \code{plot = TRUE}); a peak marks the rank
#'   at which the latent factors carry the most evenly distributed
#'   variance.}
#' @export
#' @references
#' Roy, O., & Vetterli, M. (2007).  The effective rank: A measure of
#' effective dimensionality.  \emph{Proc. 15th European Signal Processing
#' Conf. (EUSIPCO)}, 606--610.  (\code{effective.rank})
#' Wold, S. (1978).  Cross-validatory estimation of the number of
#' components in factor and principal components models.
#' \emph{Technometrics}, 20(4), 397--405.
#' \doi{10.1080/00401706.1978.10489693}  (\code{sigma.ecv})
#' @seealso \code{\link{nmfkc}},
#'   \code{\link{nmfkc.ecv}} (element-wise CV, used internally),
#'   \code{\link{nmfkc.bicv}} (block bi-cross-validation),
#'   \code{\link{nmfkc.consensus}} (stability) and
#'   \code{\link{nmfkc.ard}} (Bayesian ARD) for alternative rank criteria.
#' @examples
#' # Example.
#' Y <- t(iris[,-5])
#' # Full run (default)
#' nmfkc.rank(Y, rank=1:4)
#' # Fast run (skip ECV)
#' nmfkc.rank(Y, rank=1:4, detail="fast")

nmfkc.rank <- function(Y, A=NULL, rank=1:2, detail="full", plot=TRUE, data, ...){
  # --- Formula Mode ---
  if (base::inherits(Y, "formula")) {
    resolved <- .nmfkc_resolve_formula(Y, A, base::missing(data), if (!base::missing(data)) data else NULL)
    Y <- resolved$Y
    A <- resolved$A
  }

  extra_args <- list(...)

  # Backward compatibility: Q -> rank, save.time -> detail
  if (!is.null(extra_args$Q)) rank <- extra_args$Q
  if (!is.null(extra_args$save.time) && extra_args$save.time && detail == "full") detail <- "fast"
  Q <- rank
  # ---------------------------------------------
  num_q <- length(Q)
  results_df <- data.frame(
    rank = Q,
    effective.rank = numeric(num_q),
    effective.rank.ratio = numeric(num_q),
    r.squared = numeric(num_q),
    sigma.ecv = numeric(num_q)
  )

  # --- Main loop: per-rank fit -> effective rank + r.squared ---
  # Fitted with detail = "fast": the (O(N^2)) sample-clustering criteria
  # silhouette / CPCC / dist.cor are no longer part of rank selection.
  # For per-fit clustering quality use nmf.cluster.criteria() instead.
  for(q_idx in 1:num_q){
    current_Q <- Q[q_idx]

    extra_args_nmfkc <- extra_args
    extra_args_nmfkc$save.memory <- NULL
    extra_args_nmfkc$save.time <- NULL
    extra_args_nmfkc$detail <- "fast"
    extra_args_nmfkc$Q <- NULL

    nmfkc_args <- c(list(Y = Y, A = A, rank = current_Q), extra_args_nmfkc)
    result <- do.call("nmfkc", nmfkc_args)

    results_df$effective.rank[q_idx] <- result$criterion$effective.rank
    results_df$effective.rank.ratio[q_idx] <-
      result$criterion$effective.rank / current_Q
    results_df$r.squared[q_idx] <- result$r.squared
  }

  # --- Element-wise CV (Wold's CV) ---
  if(detail == "full"){
    ecv_args <- list(Y = Y, A = A, Q = Q)
    extra_args_ecv <- extra_args
    extra_args_ecv$save.time <- NULL
    extra_args_ecv$save.memory <- NULL
    extra_args_ecv$Q <- NULL

    ecv_full_args <- c(ecv_args, extra_args_ecv)

    message("Running Element-wise CV (this may take time)...")
    ecv_res <- do.call("nmfkc.ecv", ecv_full_args)
    results_df$sigma.ecv <- ecv_res$sigma
  } else {
    results_df$sigma.ecv <- NA
  }

  # Best-rank selection + concise 3-criterion plot (shared back-end).
  # criteria now holds only the rank-selection columns; clustering
  # quality is provided separately by nmf.cluster.criteria().
  return(.rank.finish(results_df, plot = plot,
                      main = "Rank Selection Diagnostics"))
}







#------------------------------------------------------------------------------
#------------------------------------------------------------------------------
#------------------------------------------------------------------------------
#------------------------------------------------------------------------------
#------------------------------------------------------------------------------
#------------------------------------------------------------------------------
#------------------------------------------------------------------------------
#------------------------------------------------------------------------------
#------------------------------------------------------------------------------
#------------------------------------------------------------------------------



#' @title Plot Diagnostics: Original, Fitted, and Residual Matrices as Heatmaps
#' @description
#' This function generates a side-by-side plot of three heatmaps: the original
#' observation matrix Y, the fitted matrix XB (from NMF), and the residual matrix E (Y - XB).
#' This visualization aids in diagnosing whether the chosen rank Q is adequate
#' by assessing if the residual matrix E appears to be random noise.
#'
#' The axis labels (X-axis: Samples, Y-axis: Features) are integrated into the main title of each plot
#' to maximize the plot area, reflecting the compact layout settings.
#'
#' @param Y The original observation matrix (P x N).
#' @param result The result object returned by the nmfkc function.
#' @param fitted.palette A vector of colors for Y and XB heatmaps. Defaults to white-orange-red.
#'   For backward compatibility, \code{Y_XB_palette} is accepted via \code{...}.
#' @param residual.palette A vector of colors for the residuals heatmap. Defaults to blue-white-red.
#'   For backward compatibility, \code{E_palette} is accepted via \code{...}.
#' @param ... Additional graphical parameters passed to the internal image calls.
#'
#' @return NULL. The function generates a plot.
#' @examples
#' Y <- t(iris[1:30, 1:4])
#' result <- nmfkc(Y, rank = 2)
#' nmfkc.residual.plot(Y, result)
#'
#' @seealso \code{\link{nmfkc}}, \code{\link{residuals.nmf}}
#' @export
nmfkc.residual.plot <- function(Y, result,
                                fitted.palette = grDevices::colorRampPalette(c("white", "orange", "red"))(256),
                                residual.palette = grDevices::colorRampPalette(c("blue", "white", "red"))(256), ...){
  extra_rp <- list(...)
  if (!is.null(extra_rp$Y_XB_palette)) fitted.palette <- extra_rp$Y_XB_palette
  if (!is.null(extra_rp$E_palette)) residual.palette <- extra_rp$E_palette
  if (!inherits(result, "nmfkc")) {
    stop("The 'result' argument must be an object of class 'nmfkc'.")
  }
  XB <- result$XB
  if (is.null(XB) || (length(XB) == 1 && is.na(XB))) {
    stop("'result$XB' is not available. The model may have been fitted with save.memory=TRUE.")
  }
  E <- Y - XB
  if(nrow(Y) != nrow(E) || ncol(Y) != ncol(E)){
    stop("Dimension mismatch between Y and result$XB. Check input matrices.")
  }
  old_par <- graphics::par(mfrow = c(1, 3), mar = c(1,0.5,5,0.5) + 0.1)
  on.exit(graphics::par(old_par))
  min_YX <- min(Y, XB, na.rm = TRUE)
  max_YX <- max(Y, XB, na.rm = TRUE)
  max_abs_E <- max(abs(E), na.rm = TRUE)
  min_E <- -max_abs_E
  max_E <- max_abs_E
  graphics::image(t(Y)[, nrow(Y):1],
                  col = fitted.palette,
                  zlim = c(min_YX, max_YX),
                  main = "1. Original Matrix Y\n X-axis: Samples (N), Y-axis: Features (P)",
                  xlab = "",
                  ylab = "",
                  axes = FALSE, ...)
  graphics::box()
  graphics::image(t(XB)[, nrow(XB):1],
                  col = fitted.palette,
                  zlim = c(min_YX, max_YX),
                  main = paste0("2. Fitted Matrix XB (Q=",ncol(result$X),")\n X-axis: Samples (N), Y-axis: Features (P)"),
                  xlab = "",
                  ylab = "",
                  axes = FALSE, ...)
  graphics::box()
  graphics::image(t(E)[, nrow(E):1],
                  col = residual.palette,
                  zlim = c(min_E, max_E),
                  main = "3. Residual Matrix E (Y - XB)\n X-axis: Samples (N), Y-axis: Features (P)",
                  xlab = "",
                  ylab = "",
                  axes = FALSE, ...)
  graphics::box()
}



#------------------------------------------------------------------------------
#------------------------------------------------------------------------------
#------------------------------------------------------------------------------
#------------------------------------------------------------------------------
.nmfkc_dot_digits_from_threshold <- function(threshold) {
  if (!is.finite(threshold) || threshold <= 0) {
    return(2L)
  }
  if (threshold >= 1) {
    return(0L)
  }
  s <- format(threshold, scientific = FALSE, trim = TRUE)
  # Examples: "0.01" -> "01", "0.005" -> "005"
  s_dec <- sub("^0\\.", "", s)
  nchar(s_dec)
}


#' Format coefficient values for DOT edge labels
#'
#' If \code{digits} is \code{NULL}, the function uses the traditional
#' magnitude-based formatting rules.
#' If \code{digits} is provided, the coefficient is formatted with the
#' specified number of decimal places (typically derived from the threshold).
#'
#' @param x Numeric scalar.
#' @param digits Integer or \code{NULL}; number of decimal places.
#' @return Character string suitable for use inside DOT \code{label="..."}.
#' @keywords internal
#' @noRd
.nmfkc_dot_format_coef <- function(x, digits = NULL) {
  if (!is.finite(x)) return("")

  # Default behavior: magnitude-dependent auto formatting
  if (is.null(digits)) {
    ax <- abs(x)
    if (ax == 0) return("0")

    if (ax >= 1) {
      sprintf("%.2f", x)
    } else if (ax >= 1e-1) {
      sprintf("%.3f", x)
    } else if (ax >= 1e-2) {
      sprintf("%.4f", x)
    } else {
      sprintf("%.3e", x)
    }

    # Threshold-based fixed-digit formatting
  } else {
    if (digits <= 0) {
      sprintf("%.0f", x)
    } else {
      fmt <- paste0("%.", digits, "f")
      sprintf(fmt, x)
    }
  }
}


#' Sanitize character vectors for DOT node / cluster IDs
#'
#' Replace characters that are not alphanumeric, underscore, or dot
#' with underscores to generate safe DOT identifiers.
#'
#' @param x Character vector.
#' @return Character vector with sanitized identifiers.
#' @keywords internal
#' @noRd
.nmfkc_dot_sanitize_id <- function(x) {
  gsub("[^[:alnum:]_.]", "_", x, perl = TRUE)
}


#' Generate a standard DOT graph header for NMF-related diagrams
#'
#' Provides a consistent DOT header used by NMF visualization functions.
#'
#' @param graph_name Character; name of the DOT graph.
#' @param rankdir Character; Graphviz rank direction (e.g., "LR", "RL", "TB", "BT").
#' @param fontname Character; default font name used in the graph.
#'
#' @return Character scalar containing DOT header lines.
#' @keywords internal
#' @noRd
.nmfkc_dot_header <- function(graph_name = "NMF_GRAPH",
                              rankdir    = "LR",
                              fontname   = "Arial") {
  paste0(
    "digraph ", graph_name, " {\n",
    "  graph [rankdir=", rankdir, " compound=true];\n",
    "  splines=true; nodesep=0.4; ranksep=0.7; fontname=\"", fontname, "\";\n"
  )
}


#' Generate a DOT subgraph cluster with nodes
#'
#' Internal helper to define a cluster (subgraph) of nodes with shared
#' visual properties.
#' If \code{cluster_style = "none"}, nodes are listed individually without
#' creating a DOT subgraph.
#'
#' @param cluster_id Character; cluster identifier suffix (e.g. "Y", "X", "F").
#' @param title Character; cluster label (ignored if \code{cluster_style = "none"}).
#' @param node_ids Character vector; internal DOT node IDs.
#' @param node_labels Character vector; display labels for nodes.
#' @param shape Character; Graphviz node shape.
#' @param fill Logical; whether to use filled node shapes.
#' @param fillcolor Character; fill color (if \code{fill = TRUE}).
#' @param line_width Numeric; node border width.
#' @param indent Character; indentation prefix for formatting.
#' @param cluster_style Character; boundary style ("rounded", "dashed", "invis", "none").
#' @param cluster_color Character; cluster boundary color.
#' @param cluster_penwidth Numeric; cluster boundary width.
#'
#' @return Character scalar containing DOT code for the cluster.
#' @keywords internal
#' @noRd
.nmfkc_dot_cluster_nodes <- function(cluster_id,
                                     title,
                                     node_ids,
                                     node_labels,
                                     shape      = "box",
                                     fill       = TRUE,
                                     fillcolor  = "white",
                                     line_width = 1.5,
                                     indent     = "  ",
                                     cluster_style    = "rounded",
                                     cluster_color    = "black",
                                     cluster_penwidth = 1.0) {

  if (length(node_ids) == 0L) return("")
  if (length(node_ids) != length(node_labels)) {
    stop("node_ids and node_labels must have the same length.")
  }

  # Node style definition
  if (fill) {
    node_style <- sprintf(
      '%snode [shape=%s, style="filled,rounded", fillcolor="%s", color=black, penwidth=%.1f];\n',
      indent, shape, fillcolor, line_width
    )
  } else {
    node_style <- sprintf(
      '%snode [shape=%s, style="rounded", color=black, penwidth=%.1f];\n',
      indent, shape, line_width
    )
  }

  # Case 1: no cluster → output nodes directly
  if (identical(cluster_style, "none")) {
    scr <- node_style
    for (i in seq_along(node_ids)) {
      scr <- paste0(
        scr,
        sprintf('%s%s [label="%s"];\n', indent, node_ids[i], node_labels[i])
      )
    }
    return(scr)
  }

  # Case 2: cluster with boundary
  scr <- paste0(
    indent, "subgraph cluster_", cluster_id,
    '{label="', title, '"',
    ' style="', cluster_style, '"',
    ' color="', cluster_color, '"',
    ' penwidth=', sprintf("%.1f", cluster_penwidth), ";\n",
    node_style
  )

  for (i in seq_along(node_ids)) {
    scr <- paste0(
      scr,
      sprintf('%s  %s [label="%s"];\n', indent, node_ids[i], node_labels[i])
    )
  }
  paste0(scr, indent, "}\n")
}


#' Scale DOT edge width based on coefficient magnitude
#'
#' Computes an appropriate \code{penwidth} value for Graphviz edges
#' by scaling the coefficient relative to the maximum coefficient in
#' its category.
#'
#' @param value Numeric; coefficient value.
#' @param max_value Numeric; maximum coefficient within its group.
#' @param weight_scale Numeric; global scale factor for edge width.
#' @param min_pw Numeric; minimum penwidth.
#'
#' @return Numeric penwidth value.
#' @keywords internal
#' @noRd
.nmfkc_dot_penwidth <- function(value,
                                max_value,
                                weight_scale = 5,
                                min_pw       = 0.5) {
  if (!is.finite(max_value) || max_value <= 0) return(min_pw)
  if (!is.finite(value)     || value <= 0)     return(min_pw)
  max(min_pw, value * weight_scale / max_value)
}


#' @title Statistical inference for the parameter matrix C (Theta)
#' @description
#' \code{nmfkc.inference} performs statistical inference on the parameter matrix
#' \eqn{C} (\eqn{\Theta}) from a fitted \code{nmfkc} model, conditional on
#' the estimated basis matrix \eqn{\hat{X}}.
#'
#' Under the working model \eqn{Y = X C A + \varepsilon} where
#' \eqn{\varepsilon_{pn} \stackrel{iid}{\sim} N(0, \sigma^2)},
#' inference is conducted via sandwich covariance estimation and
#' one-step wild bootstrap with non-negative projection.
#'
#' @param object An object of class \code{"nmfkc"} returned by \code{\link{nmfkc}}.
#' @param Y Observation matrix (P x N). Must match the data used in \code{nmfkc()}.
#' @param A Covariate matrix (K x N). Default is \code{NULL} (same as identity;
#'   in this case \eqn{B = C} and inference is on \eqn{B} directly).
#' @param wild.bootstrap Logical. If \code{TRUE} (default), performs wild bootstrap
#'   for confidence intervals and bootstrap standard errors. Set to \code{FALSE}
#'   to skip bootstrap (faster, only sandwich SE is computed).
#' @param ... Additional arguments:
#'   \describe{
#'     \item{\code{method}}{Bootstrap engine. \code{"onestep"} (default): a
#'       one-step Newton linearisation around the fit using the inverse
#'       information \code{Hinv} (fast; the sandwich SE is primary).
#'       \code{"refit"}: a residual wild bootstrap that re-estimates \eqn{C}
#'       to convergence with the basis \eqn{X} held FIXED. The "refit" engine
#'       uses no information matrix, so it stays valid when the Fisher
#'       information \eqn{AA'} is singular (over-parameterised covariates) or
#'       \eqn{C} sits on the \eqn{\ge 0} boundary; the bootstrap SE/CI become
#'       primary and the p-value is a two-sided bootstrap p-value.}
#'     \item{\code{wild.dist}}{Multiplier distribution (orthogonal to
#'       \code{method}): \code{"rademacher"} (\eqn{\pm 1}), \code{"mammen"}
#'       (Mammen 1993 two-point), or \code{"exp"} (\eqn{\mathrm{Exp}(1)-1}).
#'       Default \code{"exp"} for \code{method="onestep"} (backward
#'       compatible), \code{"rademacher"} for \code{"refit"}.}
#'     \item{\code{wild.unit}}{For \code{method="refit"}: \code{"element"}
#'       (default, i.i.d. multiplier per matrix cell) or \code{"column"}
#'       (one multiplier per sample column, shared over rows).}
#'     \item{\code{wild.B}}{Number of bootstrap replicates. Default is 1000.}
#'     \item{\code{wild.seed}}{Seed for bootstrap. Default is 42.}
#'     \item{\code{wild.level}}{Confidence level for bootstrap CI. Default is 0.95.}
#'     \item{\code{sandwich}}{Logical. Use sandwich covariance. Default is \code{TRUE}.}
#'     \item{\code{C.p.side}}{P-value type: \code{"one.sided"} (default) or \code{"two.sided"}.}
#'     \item{\code{cov.ridge}}{Ridge stabilization for information matrix inversion. Default is 1e-8.}
#'     \item{\code{print.trace}}{Logical. If \code{TRUE}, prints progress. Default is \code{FALSE}.}
#'   }
#'
#' @return An object of class \code{c("nmfkc.inference", "nmfkc")}, inheriting all
#' components from the input \code{object}, with additional inference components:
#' \item{sigma2.used}{Estimated \eqn{\sigma^2} used for inference.}
#' \item{C.se}{Sandwich standard errors for \eqn{C} (Q x K matrix).}
#' \item{C.se.boot}{Bootstrap standard errors for \eqn{C} (Q x K matrix).}
#' \item{C.ci.lower}{Lower CI bounds for \eqn{C} (Q x K matrix).}
#' \item{C.ci.upper}{Upper CI bounds for \eqn{C} (Q x K matrix).}
#' \item{coefficients}{Data frame with Estimate, SE, BSE, z, p-value for each element of \eqn{C}.}
#' \item{C.p.side}{P-value type used.}
#'
#' @seealso \code{\link{nmfkc}}, \code{\link{summary.nmfkc.inference}}
#' @references
#' Satoh, K. (2026). Wild Bootstrap Inference for Non-Negative Matrix
#'   Factorization with Random Effects. arXiv:2603.01468.
#'   \url{https://arxiv.org/abs/2603.01468}
#' @export
#' @examples
#' Y <- matrix(cars$dist, nrow = 1)
#' A <- rbind(intercept = 1, speed = cars$speed)
#' result <- nmfkc(Y, A, rank = 1)
#' result2 <- nmfkc.inference(result, Y, A)
#' summary(result2)
#'
nmfkc.inference <- function(object, Y, A = NULL,
                            wild.bootstrap = TRUE, ...) {
  if (!inherits(object, "nmfkc")) stop("object must be of class 'nmfkc'")

  extra_args <- base::list(...)
  wild.B      <- if (!is.null(extra_args$wild.B))      extra_args$wild.B      else 500
  wild.seed   <- if (!is.null(extra_args$wild.seed))   extra_args$wild.seed   else 123
  wild.level  <- if (!is.null(extra_args$wild.level))  extra_args$wild.level  else 0.95
  sandwich    <- if (!is.null(extra_args$sandwich))     extra_args$sandwich    else TRUE
  C.p.side    <- if (!is.null(extra_args$C.p.side))    extra_args$C.p.side    else "one.sided"
  cov.ridge   <- if (!is.null(extra_args$cov.ridge))   extra_args$cov.ridge   else 1e-8
  print.trace <- if (!is.null(extra_args$print.trace)) extra_args$print.trace else FALSE
  ## Bootstrap engine: "onestep" (Hinv-linearised; historical default) or
  ## "refit" (X-fixed residual wild bootstrap; valid when AA' is singular).
  method    <- if (!is.null(extra_args$method))
                 base::match.arg(extra_args$method, c("onestep", "refit")) else "onestep"
  wild.dist <- if (!is.null(extra_args$wild.dist))
                 base::match.arg(extra_args$wild.dist, c("rademacher", "mammen", "exp"))
               else if (method == "onestep") "exp" else "rademacher"
  wild.unit <- if (!is.null(extra_args$wild.unit))
                 base::match.arg(extra_args$wild.unit, c("element", "column")) else "element"

  X <- object$X   # P x Q
  C_mat <- object$C   # Q x K (or Q x N if A is NULL)

  # If A is NULL, use identity (B = C)
  if (is.null(A)) A <- diag(ncol(Y))

  XB <- X %*% C_mat %*% A   # P x N  fitted values
  Q <- ncol(X)
  K <- nrow(A)
  P <- nrow(Y)
  N <- ncol(Y)

  R_C <- Y - XB   # P x N  residuals

  # sigma2 estimate (denominator: PN - QK)
  denom <- max(P * N - Q * K, 1)
  sigma2.used <- sum(R_C^2) / denom

  # Information matrix: I = sigma^{-2} (AA' x X'X)
  XtX <- crossprod(X)       # Q x Q
  AAt <- tcrossprod(A)      # K x K
  Info_core <- kronecker(AAt, XtX)   # QK x QK
  Info <- Info_core / max(sigma2.used, 1e-12)
  Info <- Info + diag(cov.ridge, nrow(Info))

  Hinv <- tryCatch(solve(Info), error = function(e) {
    if (requireNamespace("MASS", quietly = TRUE)) MASS::ginv(Info)
    else stop("Information matrix singular; install MASS package.")
  })

  # Sandwich covariance: V = Hinv J Hinv
  V_sand <- NULL
  if (isTRUE(sandwich)) {
    Xt <- t(X)
    J <- matrix(0, Q * K, Q * K)
    for (n in 1:N) {
      a_n <- A[, n, drop = FALSE]
      r_n <- R_C[, n, drop = FALSE]
      g_n <- Xt %*% r_n
      S_n <- -(g_n %*% t(a_n)) / max(sigma2.used, 1e-12)
      s_n <- as.vector(S_n)
      J <- J + tcrossprod(s_n)
    }
    if (N > 1) J <- (N / (N - 1)) * J   # CR1 correction
    V_sand <- Hinv %*% J %*% Hinv
  }

  C.vec.cov <- if (!is.null(V_sand)) V_sand else Hinv

  # Sandwich SE (always computed; primary under method = "onestep")
  se_vec <- sqrt(pmax(diag(C.vec.cov), 0))
  C.se.sandwich <- matrix(se_vec, nrow = Q, ncol = K, byrow = FALSE)

  # ---- Wild bootstrap: "onestep" (Hinv-linearised) or "refit" (X-fixed) ----
  C.se.boot <- NULL
  C.ci.lower <- NULL
  C.ci.upper <- NULL
  p.boot.vec <- NULL
  C_boot <- NULL

  if (isTRUE(wild.bootstrap)) {
    if (method == "onestep") {
      Xt <- t(X)
      score_mat <- matrix(0, Q * K, N)
      for (n in 1:N) {
        a_n <- A[, n, drop = FALSE]
        r_n <- R_C[, n, drop = FALSE]
        g_n <- Xt %*% r_n
        G_n <- -(g_n %*% t(a_n)) / max(sigma2.used, 1e-12)
        score_mat[, n] <- as.vector(G_n)
      }
      C_boot <- .boot.onestep(as.vector(C_mat), score_mat, Hinv, wild.B,
                              dist = wild.dist, seed = wild.seed, project = TRUE)
    } else {                                   # method == "refit"
      ## Re-fit with nmfkc()'s own optimiser, basis X held FIXED, so the
      ## result matches the model's fitting behaviour (faithful even under
      ## singular AA', where the solution is regularisation-dependent).
      Qrank <- ncol(X)
      refit.fun <- function(Ys) nmfkc(Ys, A = A, rank = Qrank,
                                      X.init = X, X.restriction = "fixed",
                                      verbose = FALSE)$C
      C_boot <- .boot.refit(XB, R_C, refit.fun, wild.B,
                            dist = wild.dist, seed = wild.seed,
                            unit = wild.unit, clipY = TRUE)
    }
    bs <- .boot.summarize(C_boot, level = wild.level)
    C.se.boot  <- matrix(bs$se,       nrow = Q, ncol = K, byrow = FALSE)
    C.ci.lower <- matrix(bs$ci.lower, nrow = Q, ncol = K, byrow = FALSE)
    C.ci.upper <- matrix(bs$ci.upper, nrow = Q, ncol = K, byrow = FALSE)
    p.boot.vec <- bs$p.boot
  }

  # Primary SE driving z / p: bootstrap SE under "refit", sandwich under "onestep"
  C.se <- if (method == "refit" && !is.null(C.se.boot)) C.se.boot else C.se.sandwich

  # ---- Coefficients table ----
  Estimate <- as.vector(C_mat)
  SE <- as.vector(C.se)
  BSE <- if (!is.null(C.se.boot)) as.vector(C.se.boot) else rep(NA_real_, length(Estimate))
  z_value <- ifelse(SE > 0, Estimate / SE, NA_real_)

  if (method == "refit" && !is.null(p.boot.vec)) {
    p_value <- p.boot.vec               # bootstrap two-sided p (no analytical z)
  } else if (C.p.side == "one.sided") {
    p_value <- ifelse(is.finite(z_value), stats::pnorm(z_value, lower.tail = FALSE), NA_real_)
  } else {
    p_value <- ifelse(is.finite(z_value), 1 - stats::pchisq(z_value^2, df = 1), NA_real_)
  }

  # Row/column labels for C (rows = Basis, cols = Covariate)
  rlabs <- if (!is.null(rownames(C_mat))) rownames(C_mat) else paste0("Basis", 1:Q)
  clabs <- if (!is.null(colnames(C_mat))) colnames(C_mat) else paste0("Cov", 1:K)

  coefficients <- data.frame(
    Basis    = rep(rlabs, times = K),
    Covariate = rep(clabs, each = Q),
    Estimate = Estimate,
    SE       = SE,
    BSE      = BSE,
    z_value  = z_value,
    p_value  = p_value,
    CI_low   = if (!is.null(C.ci.lower)) as.vector(C.ci.lower) else NA_real_,
    CI_high  = if (!is.null(C.ci.upper)) as.vector(C.ci.upper) else NA_real_,
    row.names = NULL, stringsAsFactors = FALSE
  )

  if (print.trace) {
    if (isTRUE(wild.bootstrap)) {
      message("  Inference: sandwich SE + wild bootstrap done.")
    } else {
      message("  Inference: sandwich SE done (wild bootstrap skipped).")
    }
  }

  # Add inference fields to the object
  object$sigma2.used  <- sigma2.used
  object$C.se         <- C.se
  object$C.se.sandwich <- C.se.sandwich
  object$C.se.boot    <- C.se.boot
  object$C.ci.lower   <- C.ci.lower
  object$C.ci.upper   <- C.ci.upper
  object$coefficients <- coefficients
  object$C.p.side     <- C.p.side
  object$boot.method  <- method
  object$wild.dist    <- if (isTRUE(wild.bootstrap)) wild.dist else NA_character_
  ## Raw QK x B bootstrap draws of vec(C): lets callers form any functional
  ## (e.g. an identifiable contrast / fitted curve) and its percentile band.
  object$C.boot.draws <- C_boot
  class(object) <- c("nmfkc.inference", "nmf.inference", "nmfkc", "nmf")
  return(object)
}


#' @title Summary method for nmfkc.inference objects
#' @description
#' Produces a summary of a fitted NMF model with inference results,
#' including the coefficients table for \eqn{C} (\eqn{\Theta}).
#'
#' @param object An object of class \code{"nmfkc.inference"}.
#' @param ... Additional arguments (currently unused).
#' @return An object of class \code{"summary.nmfkc.inference"}.
#' @seealso \code{\link{nmfkc.inference}}, \code{\link{summary.nmfkc}}
#' @export
summary.nmfkc.inference <- function(object, ...) {
  ans <- summary.nmfkc(object, ...)
  ans$sigma2.used  <- object$sigma2.used
  ans$coefficients <- object$coefficients
  ans$C.p.side     <- object$C.p.side
  class(ans) <- "summary.nmfkc.inference"
  return(ans)
}


#' @title Print method for summary.nmfkc.inference objects
#' @description
#' Prints a formatted summary including the coefficients table.
#' @param x An object of class \code{"summary.nmfkc.inference"}.
#' @param digits Minimum number of significant digits.
#' @param max.coef Maximum coefficient rows to display. Default is 20.
#' @param by Character; grouping order of the coefficients table.
#'   \code{"covariate"} (default) lists all bases within each covariate
#'   (1-1, 1-2, ...); \code{"basis"} lists all covariates within each basis
#'   (1-1, 2-1, ...).
#' @param ... Additional arguments (currently unused).
#' @return Called for its side effect (printing). Returns \code{x} invisibly.
#' @seealso \code{\link{summary.nmfkc.inference}}
#' @export
print.summary.nmfkc.inference <- function(x, digits = max(3L, getOption("digits") - 3L),
                                           max.coef = 20, by = c("covariate", "basis"), ...) {
  by <- match.arg(by)
  # Print base summary
  print.summary.nmfkc(x, digits = digits, ...)

  # Print inference section
  cat("Inference (conditional on X):\n")
  cat("  sigma^2:  ", format(x$sigma2.used, digits = digits), "\n")

  # Coefficients table (formatted like lm summary)
  if (!is.null(x$coefficients) && is.data.frame(x$coefficients)) {
    cf <- x$coefficients
    cf <- cf[.coef.order.by(cf, by), , drop = FALSE]   # grouping order (by)
    n_total <- nrow(cf)
    rnames <- paste0(cf$Covariate, ":", cf$Basis)

    # Determine which rows to display
    if (n_total <= max.coef) {
      show_idx <- seq_len(n_total)
      truncated <- FALSE
    } else {
      sig_idx <- which(cf$p_value < 0.05)
      if (length(sig_idx) == 0) {
        show_idx <- seq_len(min(max.coef, n_total))
        truncated <- TRUE
      } else if (length(sig_idx) <= max.coef) {
        show_idx <- sig_idx
        truncated <- FALSE
      } else {
        show_idx <- sig_idx[seq_len(max.coef)]
        truncated <- TRUE
      }
    }

    n_sig <- sum(cf$p_value < 0.05, na.rm = TRUE)
    cat(sprintf("\nCoefficients (conditional on X): %d total, %d significant\n",
                n_total, n_sig))
    if (n_total > max.coef) {
      cat(sprintf("  (showing %d significant rows; use res$coefficients for full table)\n",
                  length(show_idx)))
    }

    p_side <- if (!is.null(x$C.p.side)) x$C.p.side else "one.sided"
    p_header <- if (p_side == "one.sided") "Pr(>z)" else "Pr(>|z|)"

    sig_stars <- function(p) {
      ifelse(!is.finite(p), " ",
        ifelse(p < 0.001, "***",
          ifelse(p < 0.01, "**",
            ifelse(p < 0.05, "*",
              ifelse(p < 0.1, ".", " ")))))
    }
    format_pval <- function(p) {
      ifelse(!is.finite(p), "      NA",
        ifelse(p < 2.2e-16, "  <2e-16",
          formatC(p, format = "g", digits = 4, width = 8)))
    }

    est <- formatC(cf$Estimate[show_idx], format = "f", digits = 3, width = 9)
    se  <- formatC(cf$SE[show_idx], format = "f", digits = 3, width = 10)
    bse <- formatC(cf$BSE[show_idx], format = "f", digits = 3, width = 6)
    zv  <- formatC(cf$z_value[show_idx], format = "f", digits = 2, width = 7)
    pv_str <- format_pval(cf$p_value[show_idx])
    stars <- sig_stars(cf$p_value[show_idx])
    show_names <- rnames[show_idx]

    max_lw <- max(nchar(show_names))
    hdr <- sprintf("%s %s %s %s %s %s",
                   formatC("Estimate", width = 9),
                   formatC("Std. Error", width = 10),
                   formatC("(Boot)", width = 6),
                   formatC("z value", width = 7),
                   formatC(p_header, width = 8), "")
    cat(sprintf("%s %s\n", formatC("Cov:Basis", width = max_lw), hdr))
    for (i in seq_along(show_names)) {
      cat(sprintf("%s %s %s %s %s %s %s\n",
                  formatC(show_names[i], width = max_lw),
                  est[i], se[i], bse[i], zv[i], pv_str[i], stars[i]))
    }
    cat("---\n")
    cat("Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1\n")
  }

  cat("\n")
  invisible(x)
}

Try the nmfkc package in your browser

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

nmfkc documentation built on July 14, 2026, 1:07 a.m.