R/linf.R

Defines functions latex.linf.csts df.to.latex escape.latex summary.linf.csts print.linf.csts expand.rare collapse.rare refine.linf.csts.iter refine.linf.csts validate.linf.csts asv.to.linf.csts linf.csts linf.active.low.freq.view linf.normalize.low.freq.policy linf.cells normalize.linf

Documented in asv.to.linf.csts collapse.rare expand.rare latex.linf.csts linf.cells linf.csts normalize.linf print.linf.csts refine.linf.csts refine.linf.csts.iter summary.linf.csts

# L-infinity normalization utilities

#' L-infinity normalization (row-wise)
#'
#' Scales each row of a numeric matrix by its L-infinity norm (row maximum).
#' Rows whose maximum is zero (or below tolerance) are left unchanged
#' and remain all-zero.
#'
#' @param X Numeric matrix (samples x features).
#' @param tol Numeric >= 0. Values with row max <= tol are treated as zero rows.
#'   Default: 0 (exact zero only).
#' @param backend Character. Matrix backend to use: \code{"auto"},
#'   \code{"dense"}, or \code{"sparse"}. The default \code{"auto"} preserves
#'   sparse input and otherwise uses the dense path.
#'
#' @return Numeric matrix of same dimensions as X, L-infinity normalized.
#'
#' @details
#' Zero rows have undefined L-infinity direction. By convention, they are
#' preserved as all-zero rows and will yield NA labels in downstream
#' dominant-feature or dCST assignment.
#'
#' @export
normalize.linf <- function(X,
                           tol = 0,
                           backend = c("auto", "dense", "sparse")) {
  prep <- linf.prepare.matrix(X, backend = backend, fun.name = "normalize.linf")
  X <- prep$X
  backend <- prep$backend

  linf.validate.matrix(X, backend = backend, fun.name = "normalize.linf")
  if (!is.numeric(tol) || length(tol) != 1L || tol < 0) {
    stop("normalize.linf: tol must be a single non-negative number")
  }

  if (backend == "sparse") {
    return(linf.normalize.sparse(X, tol = tol))
  }

  m <- apply(X, 1, max)

  ## Identify rows with meaningful L-infinity norm
  keep <- m > tol

  if (any(keep)) {
    X[keep, ] <- X[keep, , drop = FALSE] / m[keep]
  }

  X
}

#' Dominant-feature assignment
#'
#' @description
#' Assigns each row to the column achieving its maximum.
#'
#' For each sample (row) of a nonnegative matrix, identifies the dominant
#' feature as the column with the maximum value. Samples with the same dominant
#' feature form a depth-1 dominance sample set. Ties are broken by the first
#' maximum (as in \code{max.col(..., ties.method = "first")}). Rows that are
#' all zero are assigned \code{NA}.
#'
#' Feature IDs default to \code{colnames(S)}; if absent, synthetic IDs
#' \code{"V1", "V2", ..., "Vp"} are generated. Display labels default to the
#' feature IDs unless \code{feature.labels} is supplied. To guarantee a 1-1
#' mapping between columns and both IDs and labels, duplicates are
#' disambiguated via \code{make.unique()}.
#'
#' @param S Numeric matrix (samples x features), typically L-infinity-normalized.
#' @param feature.ids Optional character vector of stable feature identifiers,
#'   length \code{ncol(S)}.
#' @param feature.labels Optional character vector of display labels, length
#'   \code{ncol(S)}.
#' @param tie.method Character. How to resolve ties during dominant-feature
#'   assignment.
#' @param return.value Logical. If `TRUE`, include a `value` vector with row maxima.
#' @param backend Character. Matrix backend to use: \code{"auto"},
#'   \code{"dense"}, or \code{"sparse"}. The default \code{"auto"} preserves
#'   sparse input and otherwise uses the dense path.
#'
#' @return A list with components:
#' \itemize{
#'   \item \code{index}: integer index of the dominant column per sample (`NA` for all-zero rows)
#'   \item \code{id}: dominant feature ID per sample (`NA` for all-zero rows)
#'   \item \code{label}: dominant column label per sample (`NA` for all-zero rows)
#'   \item \code{id.levels}: full feature ID set after `make.unique(..., sep = "_")`
#'   \item \code{levels}: full column label set after `make.unique(..., sep = "_")`
#'   \item \code{observed.id.levels}: subset of \code{id.levels} that appear in \code{id}
#'   \item \code{observed.levels}: subset of \code{levels} that appear in \code{label}
#'   \item \code{value}: row maxima (only when \code{return.value = TRUE})
#' }
#'
#' @examples
#' # Basic example with named columns
#' S <- rbind(
#'   a = c(A = 10, B = 5,  C = 0),   # -> A
#'   b = c(A = 0,  B = 0,  C = 0),   # -> NA
#'   c = c(A = 1,  B = 4,  C = 4)    # tie -> first max: B
#' )
#' out <- linf.cells(S)
#' out$index
#' out$label
#' out$levels
#' out$observed.levels
#'
#' # Unnamed columns (synthetic labels V1..Vp), duplicate names disambiguated
#' T <- matrix(c(0,2,  3,1,  0,0), nrow = 3, byrow = TRUE)
#' colnames(T) <- c("X", "X")  # duplicates -> X, X_1
#' linf.cells(T)$levels
#'
#' # With L-infinity normalization in a pipeline
#' M <- normalize.linf(S)
#' linf.cells(M)$label
#'
#' @seealso \code{\link{normalize.linf}}, \code{\link{linf.csts}}
#' @export
linf.cells <- function(S,
                       feature.ids = NULL,
                       feature.labels = NULL,
                       tie.method = c("first", "random", "error"),
                       return.value = FALSE,
                       backend = c("auto", "dense", "sparse")) {

  tie.method <- match.arg(tie.method)
  backend <- linf.resolve.backend(S, backend)

  if (backend == "sparse") {
    return(linf.cells.sparse(
      S,
      feature.ids = feature.ids,
      feature.labels = feature.labels,
      tie.method = tie.method,
      return.value = return.value
    ))
  }

  prep <- linf.prepare.matrix(S, backend = "dense", fun.name = "linf.cells")
  X <- prep$X
  linf.validate.matrix(X, backend = "dense", fun.name = "linf.cells")

  meta <- resolve.linf.feature.meta(X, feature.ids = feature.ids, feature.labels = feature.labels)
  id.lev <- meta$feature.ids
  lev <- meta$feature.labels

  idx <- rep(NA_integer_, nrow(X))
  val <- apply(X, 1, max)

  nz <- val > 0
  if (any(nz)) {
    idx[nz] <- apply(X[nz, , drop = FALSE], 1, function(r) {
      m <- max(r)
      j <- which(r == m)
      if (length(j) == 1L) return(j)
      if (tie.method == "first") return(j[1L])
      if (tie.method == "random") return(sample(j, 1L))
      stop("linf.cells: tie encountered and tie.method = 'error'")
    })
  }

  id <- ifelse(is.na(idx), NA_character_, id.lev[idx])
  lbl <- ifelse(is.na(idx), NA_character_, lev[idx])

  observed.id.levels <- id.lev[id.lev %in% id[!is.na(id)]]
  observed.levels <- lev[lev %in% lbl[!is.na(lbl)]]

  out <- list(
    index = idx,
    id = id,
    label = lbl,
    id.levels = id.lev,
    levels = lev,
    observed.id.levels = observed.id.levels,
    observed.levels = observed.levels
  )

  if (return.value) {
    out$value <- val
  }

  out
}

linf.normalize.low.freq.policy <- function(low.freq.policy, fun.name) {
  if (length(low.freq.policy) > 1L) {
    low.freq.policy <- low.freq.policy[[1L]]
  }

  low.freq.policy <- match.arg(
    low.freq.policy,
    choices = c("pure", "absorb", "rare")
  )

  if (identical(low.freq.policy, "rare")) {
    warning(
      sprintf(
        '%s: low.freq.policy = "rare" is deprecated; use "pure" instead',
        fun.name
      ),
      call. = FALSE
    )
    return("pure")
  }

  low.freq.policy
}

linf.active.low.freq.view <- function(low.freq.policy) {
  if (is.null(low.freq.policy)) {
    return("active")
  }

  if (identical(low.freq.policy, "pure") || identical(low.freq.policy, "rare")) {
    return("rare")
  }

  low.freq.policy
}

#' Truncated dominant community state types with configurable low-support handling
#'
#' @description
#' Forms provisional depth-1 dominance sample sets from the dominant feature
#' of each sample and then applies the minimum support threshold \code{n0}.
#' Sets with fewer than \code{n0} samples are handled according to
#' \code{low.freq.policy}:
#' \itemize{
#'   \item \code{"pure"}: retain only sets with support >= \code{n0} as named
#'     dCSTs and collapse all low-support sets into \code{rare.label}.
#'   \item \code{"absorb"}: reassign each low-support sample to the retained
#'     state with the largest value among the retained features (ties handled
#'     by \code{tie.method}).
#' }
#'
#' @param S Numeric matrix (samples x features), typically L-infinity relatives.
#' @param feature.ids Optional character vector of stable feature identifiers,
#'   length \code{ncol(S)}.
#' @param feature.labels Optional character vector of display labels, length
#'   \code{ncol(S)}.
#' @param n0 Integer >= 1. Minimum support required to retain a dominance
#'   sample set.
#' @param low.freq.policy Character. One of \code{"pure"} or \code{"absorb"}.
#'   Default: \code{"pure"}. The legacy value \code{"rare"} is still accepted as
#'   a deprecated alias for \code{"pure"}.
#' @param rare.label Character scalar used when \code{low.freq.policy = "pure"}.
#'   Default: \code{"RARE_DOMINANT"}.
#' @param tie.method Character. Tie handling passed to \code{linf.cells()} and used
#'   during absorb reassignment ("first", "random", "error").
#' @param return.diagnostics Logical. If TRUE, return reassignment diagnostics.
#' @param return.landmarks Logical. If TRUE, attach a depth-1 landmark summary
#'   computed by \code{\link{linf.landmarks}}.
#' @param landmark.types Character vector of landmark types passed to
#'   \code{\link{linf.landmarks}} when \code{return.landmarks = TRUE}.
#' @param landmark.view Character. Landmark view passed to
#'   \code{\link{linf.landmarks}} when \code{return.landmarks = TRUE}.
#' @param backend Character. Matrix backend to use: \code{"auto"},
#'   \code{"dense"}, or \code{"sparse"}. The default \code{"auto"} preserves
#'   sparse input and otherwise uses the dense path.
#'
#' @return List with:
#'   \itemize{
#'     \item \code{cell.index}, \code{cell.id}, \code{cell.label}: active labeling per \code{low.freq.policy}
#'     \item \code{cell.index.rare}, \code{cell.id.rare}, \code{cell.label.rare}
#'     \item \code{cell.index.absorb}, \code{cell.id.absorb}, \code{cell.label.absorb}
#'     \item \code{kept.cells.idx}, \code{kept.cells.id}, \code{kept.cells.lbl}
#'     \item \code{raw.index}, \code{raw.id}, \code{raw.label}
#'     \item \code{feature.ids}, \code{feature.labels}
#'     \item \code{size.table}, \code{size.table.id}
#'     \item \code{n0}, \code{low.freq.policy}, \code{rare.label}
#'     \item \code{diagnostics} (if \code{return.diagnostics = TRUE})
#'     \item \code{landmarks} (if \code{return.landmarks = TRUE})
#'   }
#'
#' @export
linf.csts <- function(S,
                      feature.ids = NULL,
                      feature.labels = NULL,
                      n0 = 50,
                      low.freq.policy = c("pure", "absorb"),
                      rare.label = "RARE_DOMINANT",
                      tie.method = c("first", "random", "error"),
                      return.diagnostics = FALSE,
                      return.landmarks = FALSE,
                      landmark.types = c("endpoint.max", "endpoint.min"),
                      landmark.view = c("active", "rare", "absorb"),
                      backend = c("auto", "dense", "sparse")) {

    low.freq.policy <- linf.normalize.low.freq.policy(low.freq.policy, "linf.csts")
    tie.method <- match.arg(tie.method)
    landmark.view <- match.arg(landmark.view)
    prep <- linf.prepare.matrix(S, backend = backend, fun.name = "linf.csts")
    X <- prep$X
    backend <- prep$backend

    if (!is.numeric(n0) || length(n0) != 1L || n0 < 1 || n0 %% 1 != 0) {
        stop("linf.csts: n0 must be integer >= 1")
    }
    if (!is.character(rare.label) || length(rare.label) != 1L || !nzchar(rare.label)) {
        stop("linf.csts: rare.label must be a non-empty character scalar")
    }

    linf.validate.matrix(X, backend = backend, fun.name = "linf.csts")

    meta <- resolve.linf.feature.meta(X, feature.ids = feature.ids, feature.labels = feature.labels)
    fid <- meta$feature.ids
    lev <- meta$feature.labels

    raw <- linf.cells(X,
                      feature.ids = fid,
                      feature.labels = lev,
                      tie.method = tie.method,
                      backend = backend)
    tab <- sort(table(raw$label[!is.na(raw$label)]), decreasing = TRUE)
    tab.id <- sort(table(raw$id[!is.na(raw$id)]), decreasing = TRUE)

    kept.lbl <- names(tab[tab >= n0])
    kept.idx <- match(kept.lbl, lev)
    kept.id <- fid[kept.idx]

    n <- nrow(X)

    ## Pure-policy labels: retain only states with support >= n0.
    is.kept <- !is.na(raw$label) & (raw$label %in% kept.lbl)

    cell.idx.rare <- raw$index
    cell.id.rare <- raw$id
    cell.lbl.rare <- raw$label
    cell.idx.rare[!is.kept] <- NA_integer_
    cell.id.rare[!is.kept] <- rare.label
    cell.lbl.rare[!is.kept] <- rare.label

    ## Absorb-policy labels: reassign low-support (and zero-row) samples
    ## into retained states.
    cell.idx.absorb <- raw$index
    cell.id.absorb <- raw$id
    cell.lbl.absorb <- raw$label

    reassigned <- logical(n)
    reassigned.from <- rep(NA_character_, n)
    reassigned.to   <- rep(NA_character_, n)

    if (length(kept.idx) > 0L) {

        ## Fallback target for degenerate cases (e.g., all kept values are zero)
        fallback.idx <- kept.idx[1L]
        fallback.lbl <- lev[fallback.idx]

        ## Absorb: (i) low-frequency raw labels, (ii) raw NA labels (e.g., all-zero rows)
        to.absorb <- which(!is.kept)

        if (length(to.absorb)) {
            if (backend == "sparse") {
                new.idx <- linf.absorb.sparse(
                    X,
                    to.absorb = to.absorb,
                    kept.idx = kept.idx,
                    tie.method = tie.method,
                    fallback.idx = fallback.idx
                )
            } else {
                new.idx <- apply(X[to.absorb, , drop = FALSE], 1, function(r) {
                    kvals <- r[kept.idx]
                    m <- max(kvals)

                    ## If there is no positive evidence among kept taxa, avoid arbitrary ties
                    if (!is.finite(m) || m <= 0) return(fallback.idx)

                    j <- kept.idx[kvals == m]
                    if (length(j) == 1L) return(j)
                    if (tie.method == "first") return(j[1L])
                    if (tie.method == "random") return(sample(j, 1L))
                    stop("linf.csts: tie during reassignment and tie.method = 'error'")
                })
            }

            reassigned[to.absorb] <- TRUE
            reassigned.from[to.absorb] <- raw$label[to.absorb]
            reassigned.to[to.absorb]   <- lev[new.idx]

            cell.idx.absorb[to.absorb] <- new.idx
            cell.id.absorb[to.absorb] <- fid[new.idx]
            cell.lbl.absorb[to.absorb] <- lev[new.idx]
        }

    } else {

        ## No retained states at this n0:
        ## - rare-policy: everyone is rare.label (already set above)
        ## - absorb-policy: undefined; keep NA labels
        cell.idx.absorb[] <- NA_integer_
        cell.id.absorb[] <- NA_character_
        cell.lbl.absorb[] <- NA_character_
    }

    ## Select active labeling
    if (low.freq.policy == "pure") {
        cell.idx <- cell.idx.rare
        cell.id <- cell.id.rare
        cell.lbl <- cell.lbl.rare
    } else {
        cell.idx <- cell.idx.absorb
        cell.id <- cell.id.absorb
        cell.lbl <- cell.lbl.absorb
    }

    names(cell.idx) <- rownames(X)
    names(cell.id) <- rownames(X)
    names(cell.lbl) <- rownames(X)

    names(cell.idx.rare) <- rownames(X)
    names(cell.id.rare) <- rownames(X)
    names(cell.lbl.rare) <- rownames(X)

    names(cell.idx.absorb) <- rownames(X)
    names(cell.id.absorb) <- rownames(X)
    names(cell.lbl.absorb) <- rownames(X)

    out <- list(
        cell.index       = cell.idx,
        cell.id          = cell.id,
        cell.label       = cell.lbl,
        cell.index.rare  = cell.idx.rare,
        cell.id.rare     = cell.id.rare,
        cell.label.rare  = cell.lbl.rare,
        cell.index.absorb = cell.idx.absorb,
        cell.id.absorb   = cell.id.absorb,
        cell.label.absorb = cell.lbl.absorb,
        kept.cells.idx   = kept.idx,
        kept.cells.id    = kept.id,
        kept.cells.lbl   = kept.lbl,
        raw.index        = raw$index,
        raw.id           = raw$id,
        raw.label        = raw$label,
        size.table       = tab,
        size.table.id    = tab.id,
        feature.ids      = fid,
        feature.labels   = lev,
        matrix.backend   = backend,
        n0               = as.integer(n0),
        low.freq.policy  = low.freq.policy,
        rare.label       = rare.label
    )

    if (return.diagnostics) {
        out$diagnostics <- list(
            reassigned      = reassigned,
            reassigned.from = reassigned.from,
            reassigned.to   = reassigned.to
        )
    }

    out$cst.id.levels <- list(level1 = out$cell.id)
    out$cst.id.levels.rare <- list(level1 = out$cell.id.rare)
    out$cst.id.levels.absorb <- list(level1 = out$cell.id.absorb)
    out$cst.levels <- list(level1 = out$cell.label)
    out$cst.levels.rare <- list(level1 = out$cell.label.rare)
    out$cst.levels.absorb <- list(level1 = out$cell.label.absorb)
    out$cst.depth <- 1L

    class(out) <- "linf.csts"

    if (isTRUE(return.landmarks)) {
        out$landmarks <- linf.landmarks(
            X,
            out,
            depth = 1L,
            view = landmark.view,
            landmark.types = landmark.types,
            tie.method = tie.method,
            backend = backend
        )
    }

    out
}

#' From counts to filtered, normalized, truncated dCSTs
#'
#' Convenience pipeline: runs \code{filter.asv()} on counts, computes
#' L-infinity-normalized relatives on the filtered counts, and assigns
#' truncated dCSTs.
#'
#' @param S.counts Numeric matrix of counts (samples x features).
#' @param ... Arguments passed to \code{filter.asv()} (e.g., \code{min.lib},
#'   \code{prev.prop}, \code{min.count}, \code{min.rel}).
#' @param backend Character. Matrix backend to use: \code{"auto"},
#'   \code{"dense"}, or \code{"sparse"}.
#' @return A list with:
#' \itemize{
#'   \item \code{filter}: the full result from \code{filter.asv()}.
#'   \item \code{linf.rel}: L-infinity-normalized matrix on filtered counts.
#'   \item \code{csts}: result from \code{linf.csts()} on \code{linf.rel}.
#' }
#'
#' @examples
#' set.seed(1)
#' S <- matrix(rpois(100, lambda = 5), nrow = 20, ncol = 5)
#' res <- asv.to.linf.csts(S, min.lib = 10, prev.prop = 0.1, min.count = 1)
#' names(res)
#' apply(res$linf.rel, 1, max)  # should be 1 (or 0 for all-zero rows)
#'
#' @export
asv.to.linf.csts <- function(S.counts,
                             ...,
                             backend = c("auto", "dense", "sparse")) {
  filt <- filter.asv(S.counts, ...)
  M <- normalize.linf(filt$counts, backend = backend)
  cst <- linf.csts(M, backend = backend)
  list(filter = filt, linf.rel = M, csts = cst)
}

validate.linf.csts <- function(obj) {

  stopifnot(is.list(obj))
  stopifnot(!is.null(obj$cell.label))

  n <- length(obj$cell.label)

  ## Case 1: explicit hierarchy (refined dCSTs)
  if (!is.null(obj$cst.levels)) {

    stopifnot(is.list(obj$cst.levels))
    stopifnot(!is.null(obj$cst.depth))
    stopifnot(obj$cst.depth == length(obj$cst.levels))

    for (lvl in obj$cst.levels) {
      stopifnot(length(lvl) == n)
    }

    return(invisible(TRUE))
  }

  ## Case 2: depth-1 dCST (plain linf.csts output)
  ## This is VALID and should be accepted
  return(invisible(TRUE))
}

#' Refine a dCST hierarchy by one level
#'
#' @description
#' Selects well-supported leaf dominance-lineages and refines them by dropping
#' the dominant feature(s) encoded in the parent label path and re-applying
#' \code{\link{linf.csts}} to the remaining features. The resulting child
#' labels are appended to the parent label using \code{sep}.
#'
#' Low-support child lineages are handled by \code{low.freq.policy}. When
#' \code{low.freq.policy = "pure"}, rare buckets at depth >= 2 become
#' parent-prefixed automatically via the hierarchical \code{paste(parent, child, sep = sep)}.
#'
#' @param M Numeric matrix (samples x features) used for refinement. Column names
#'   should match feature labels used in dCST names.
#' @param csts A \code{"linf.csts"} object.
#' @param n0 Integer >= 1. Minimum support required to retain a child lineage
#'   (passed to \code{linf.csts}).
#' @param refinement.factor Numeric > 0. Auto-refine parent lineages with
#'   support >= \code{refinement.factor * n0}.
#' @param sep Character scalar used to concatenate hierarchical labels.
#' @param low.freq.policy Character. One of \code{"pure"} or \code{"absorb"}.
#'   Default: \code{"pure"}. The legacy value \code{"rare"} is still accepted as
#'   a deprecated alias for \code{"pure"}.
#' @param rare.label Character scalar for rare buckets when \code{low.freq.policy = "pure"}.
#' @param verbose Logical. If TRUE, print progress information.
#' @param backend Character. Matrix backend to use: \code{"auto"},
#'   \code{"dense"}, or \code{"sparse"}. The default \code{"auto"} inherits the
#'   backend from \code{M} or from \code{csts} when available.
#'
#' @return Updated \code{"linf.csts"} object with \code{cst.depth} increased by one and
#'   updated \code{cell.label}. Policy-specific views are stored in
#'   \code{cell.label.rare} and \code{cell.label.absorb}.
#'
#' @export
refine.linf.csts <- function(M,
                             csts,
                             n0 = 50,
                             refinement.factor = 2,
                             sep = "__",
                             low.freq.policy = c("pure", "absorb"),
                             rare.label = "RARE_DOMINANT",
                             verbose = TRUE,
                             backend = c("auto", "dense", "sparse")) {

    validate.linf.csts(csts)

    low.freq.policy <- linf.normalize.low.freq.policy(low.freq.policy, "refine.linf.csts")
    if (missing(backend) && !is.null(csts$matrix.backend)) {
        backend <- csts$matrix.backend
    }
    prep <- linf.prepare.matrix(M, backend = backend, fun.name = "refine.linf.csts")
    M <- prep$X
    backend <- prep$backend
    linf.validate.matrix(M, backend = backend, fun.name = "refine.linf.csts")

    if (!is.numeric(n0) || length(n0) != 1L || n0 < 1 || n0 %% 1 != 0) {
        stop("refine.linf.csts: n0 must be integer >= 1")
    }
    if (!is.numeric(refinement.factor) || length(refinement.factor) != 1L ||
        !is.finite(refinement.factor) || refinement.factor <= 0) {
        stop("refine.linf.csts: refinement.factor must be a finite numeric > 0")
    }
    if (!is.character(sep) || length(sep) != 1L || !nzchar(sep)) {
        stop("refine.linf.csts: sep must be a non-empty character scalar")
    }
    if (!is.character(rare.label) || length(rare.label) != 1L || !nzchar(rare.label)) {
        stop("refine.linf.csts: rare.label must be a non-empty character scalar")
    }

    ## Initialize hierarchy if missing
    if (is.null(csts$cst.levels)) {
        csts$cst.levels <- list(level1 = csts$cell.label)
        csts$cst.depth <- 1L
        csts$sep <- sep

        ## Ensure both policy-specific views exist at depth 1
        if (is.null(csts$cell.label.rare)) {
            csts$cell.label.rare <- csts$cell.label
        }
        if (is.null(csts$cell.label.absorb)) {
            csts$cell.label.absorb <- csts$cell.label
        }

        csts$cst.levels.rare <- list(level1 = csts$cell.label.rare)
        csts$cst.levels.absorb <- list(level1 = csts$cell.label.absorb)
    }

    if (is.null(csts$cst.id.levels)) {
        csts$cst.id.levels <- list(level1 = csts$cell.id %||% csts$cell.label)
    }

    if (is.null(csts$feature.ids)) {
        csts$feature.ids <- colnames(M)
    }
    if (is.null(csts$feature.labels)) {
        csts$feature.labels <- csts$feature.ids
    }

    ## Backward-compatible defaults if stored views are missing
    if (is.null(csts$cst.levels.rare)) csts$cst.levels.rare <- csts$cst.levels
    if (is.null(csts$cst.levels.absorb)) csts$cst.levels.absorb <- csts$cst.levels
    if (is.null(csts$cst.id.levels.rare)) csts$cst.id.levels.rare <- csts$cst.id.levels
    if (is.null(csts$cst.id.levels.absorb)) csts$cst.id.levels.absorb <- csts$cst.id.levels

    depth <- csts$cst.depth + 1L
    parent.ids <- csts$cst.id.levels[[depth - 1L]]
    parent.ids.rare <- csts$cst.id.levels.rare[[depth - 1L]]
    parent.ids.absorb <- csts$cst.id.levels.absorb[[depth - 1L]]
    parent.labels <- csts$cst.levels[[depth - 1L]]

    parent.labels.rare <- csts$cst.levels.rare[[depth - 1L]]
    parent.labels.absorb <- csts$cst.levels.absorb[[depth - 1L]]

    refined.ids.rare <- parent.ids.rare
    refined.ids.absorb <- parent.ids.absorb
    refined.labels.rare <- parent.labels.rare
    refined.labels.absorb <- parent.labels.absorb

    cell.sizes <- sort(table(parent.ids), decreasing = TRUE)

    threshold <- refinement.factor * n0
    refine.cells <- names(cell.sizes[cell.sizes >= threshold])

    if (verbose) {
        cat("========================================\n")
        cat("AUTO-REFINEMENT MODE\n")
        cat("========================================\n")
        cat("Refinement threshold:", threshold, "\n")
        cat("Dominance-lineages selected for refinement:", length(refine.cells), "\n\n")
    }

    for (cell in refine.cells) {
        ## Rare buckets are synthetic catch-all groups, not real feature paths.
        ## They should never be refined further.
        if (identical(cell, rare.label)) next

        idx <- which(parent.ids == cell)
        parent.id.rare <- parent.ids.rare[idx[1L]]
        parent.id.absorb <- parent.ids.absorb[idx[1L]]
        parent.label.rare <- parent.labels.rare[idx[1L]]
        parent.label.absorb <- parent.labels.absorb[idx[1L]]

        parent.features <- strsplit(cell, sep, fixed = TRUE)[[1]]
        drop.idx <- match(parent.features, csts$feature.ids)
        drop.idx <- drop.idx[!is.na(drop.idx)]

        ## If no parent features match columns (e.g., the lineage is a rare
        ## bucket), do not drop any columns.
        ## Note: x[, -integer(0)] selects *zero* columns, so we must handle this explicitly.
        if (length(drop.idx) == 0L) {
            M.sub <- M[idx, , drop = FALSE]
        } else if (length(drop.idx) >= ncol(M)) {
            ## Dropping all columns would yield an empty matrix; nothing to refine.
            next
        } else {
            M.sub <- M[idx, -drop.idx, drop = FALSE]
        }

        if (nrow(M.sub) == 0L || ncol(M.sub) == 0L) next

        sub.csts <- linf.csts(M.sub,
                             feature.ids = csts$feature.ids[-drop.idx],
                             feature.labels = csts$feature.labels[-drop.idx],
                             n0 = n0,
                             low.freq.policy = low.freq.policy,
                             rare.label = rare.label,
                             backend = backend)

        sub.ids.rare <- sub.csts$cell.id.rare %||% sub.csts$cell.label.rare
        sub.ids.absorb <- sub.csts$cell.id.absorb %||% sub.csts$cell.label.absorb
        sub.labels.rare <- sub.csts$cell.label.rare
        sub.labels.absorb <- sub.csts$cell.label.absorb

        ## If refinement yields no retained child lineages, skip.
        if (all(sub.labels.rare == rare.label)) next

        refined.ids.rare[idx] <- paste(parent.id.rare, sub.ids.rare, sep = sep)
        refined.ids.absorb[idx] <- paste(parent.id.absorb, sub.ids.absorb, sep = sep)
        refined.labels.rare[idx] <- paste(parent.label.rare, sub.labels.rare, sep = sep)
        refined.labels.absorb[idx] <- paste(parent.label.absorb, sub.labels.absorb, sep = sep)
    }

    ## Update dCST object (store both views; active view chosen by low.freq.policy)
    csts$cst.id.levels[[depth]] <- if (low.freq.policy == "pure") refined.ids.rare else refined.ids.absorb
    csts$cst.id.levels.rare[[depth]] <- refined.ids.rare
    csts$cst.id.levels.absorb[[depth]] <- refined.ids.absorb
    csts$cst.levels.rare[[depth]] <- refined.labels.rare
    csts$cst.levels.absorb[[depth]] <- refined.labels.absorb

    if (low.freq.policy == "pure") {
        refined.labels <- refined.labels.rare
    } else {
        refined.labels <- refined.labels.absorb
    }

    csts$cst.levels[[depth]] <- refined.labels
    csts$cst.depth <- depth
    csts$sep <- sep

    csts$cell.id.rare <- refined.ids.rare
    csts$cell.id.absorb <- refined.ids.absorb
    csts$cell.id <- csts$cst.id.levels[[depth]]
    csts$cell.label.rare <- refined.labels.rare
    csts$cell.label.absorb <- refined.labels.absorb
    csts$cell.label <- refined.labels

    csts$low.freq.policy <- low.freq.policy
    csts$rare.label <- rare.label
    csts$matrix.backend <- backend
    csts$landmarks <- NULL

    class(csts) <- "linf.csts"

    csts
}

#' Iteratively refine dCSTs by one additional level
#'
#' @description
#' Appends one refinement level to an existing dCST hierarchy produced by
#' \code{\link{refine.linf.csts}} or \code{\link{refine.linf.csts.iter}}.
#' Dominance-lineages to refine can be provided explicitly or selected
#' automatically based on \code{refinement.factor * n0}.
#'
#' @param M Numeric matrix used for refinement.
#' @param refined A \code{"linf.csts"} object with an existing hierarchy.
#' @param cells.to.refine Character vector of leaf dominance-lineage IDs to
#'   refine, or NULL for auto-selection. The argument name is retained for
#'   backward compatibility.
#' @param n0 Integer >= 1. Minimum support required to retain a child lineage.
#' @param refinement.factor Numeric > 0. Auto-selection threshold multiplier.
#' @param sep Character scalar used to concatenate hierarchical labels.
#' @param low.freq.policy Character. One of \code{"pure"} or \code{"absorb"}.
#'   Default: \code{"pure"}. The legacy value \code{"rare"} is still accepted as
#'   a deprecated alias for \code{"pure"}.
#' @param rare.label Character scalar for rare buckets when \code{low.freq.policy = "pure"}.
#' @param verbose Logical. If TRUE, print progress information.
#' @param backend Character. Matrix backend to use: \code{"auto"},
#'   \code{"dense"}, or \code{"sparse"}. The default \code{"auto"} inherits the
#'   backend from \code{M} or from \code{refined} when available.
#'
#' @return Updated \code{"linf.csts"} object with \code{cst.depth} increased by one.
#'
#' @export
refine.linf.csts.iter <- function(M,
                                  refined,
                                  cells.to.refine = NULL,
                                  n0 = 50,
                                  refinement.factor = 5,
                                  sep = "__",
                                  low.freq.policy = c("pure", "absorb"),
                                  rare.label = "RARE_DOMINANT",
                                  verbose = TRUE,
                                  backend = c("auto", "dense", "sparse")) {

    validate.linf.csts(refined)

    low.freq.policy <- linf.normalize.low.freq.policy(low.freq.policy, "refine.linf.csts.iter")
    if (missing(backend) && !is.null(refined$matrix.backend)) {
        backend <- refined$matrix.backend
    }
    prep <- linf.prepare.matrix(M, backend = backend, fun.name = "refine.linf.csts.iter")
    M <- prep$X
    backend <- prep$backend
    linf.validate.matrix(M, backend = backend, fun.name = "refine.linf.csts.iter")

    if (!is.numeric(n0) || length(n0) != 1L || n0 < 1 || n0 %% 1 != 0) {
        stop("refine.linf.csts.iter: n0 must be integer >= 1")
    }
    if (!is.numeric(refinement.factor) || length(refinement.factor) != 1L ||
        !is.finite(refinement.factor) || refinement.factor <= 0) {
        stop("refine.linf.csts.iter: refinement.factor must be a finite numeric > 0")
    }
    if (!is.character(sep) || length(sep) != 1L || !nzchar(sep)) {
        stop("refine.linf.csts.iter: sep must be a non-empty character scalar")
    }
    if (!is.character(rare.label) || length(rare.label) != 1L || !nzchar(rare.label)) {
        stop("refine.linf.csts.iter: rare.label must be a non-empty character scalar")
    }

    ## Ensure stored views exist (backward compatible)
    if (is.null(refined$cst.levels.rare)) refined$cst.levels.rare <- refined$cst.levels
    if (is.null(refined$cst.levels.absorb)) refined$cst.levels.absorb <- refined$cst.levels
    if (is.null(refined$cst.id.levels)) refined$cst.id.levels <- list(level1 = refined$cell.id %||% refined$cell.label)
    if (is.null(refined$cst.id.levels.rare)) refined$cst.id.levels.rare <- refined$cst.id.levels
    if (is.null(refined$cst.id.levels.absorb)) refined$cst.id.levels.absorb <- refined$cst.id.levels
    if (is.null(refined$feature.ids)) refined$feature.ids <- colnames(M)
    if (is.null(refined$feature.labels)) refined$feature.labels <- refined$feature.ids

    depth <- refined$cst.depth

    parent.ids <- refined$cst.id.levels[[depth]]
    parent.ids.rare <- refined$cst.id.levels.rare[[depth]]
    parent.ids.absorb <- refined$cst.id.levels.absorb[[depth]]
    parent.labels <- refined$cst.levels[[depth]]
    parent.labels.rare <- refined$cst.levels.rare[[depth]]
    parent.labels.absorb <- refined$cst.levels.absorb[[depth]]

    cell.sizes <- sort(table(parent.ids), decreasing = TRUE)
    threshold <- refinement.factor * n0

    if (is.null(cells.to.refine)) {
        cells.to.refine <- names(cell.sizes[cell.sizes >= threshold])
    }

    if (verbose) {
        cat("Auto-selecting dominance-lineages for iterative refinement (threshold =", threshold, "):\n")
        for (c in cells.to.refine) {
            cat(" -", c, ":", cell.sizes[c], "samples\n")
        }
        cat("\n")
    }

    new.ids.rare <- parent.ids.rare
    new.ids.absorb <- parent.ids.absorb
    new.labels.rare <- parent.labels.rare
    new.labels.absorb <- parent.labels.absorb

    for (cell in cells.to.refine) {
        ## Rare buckets are synthetic catch-all groups, not real feature paths.
        ## They should never be refined further.
        if (identical(cell, rare.label)) next

        idx <- which(parent.ids == cell)
        parent.id.rare <- parent.ids.rare[idx[1L]]
        parent.id.absorb <- parent.ids.absorb[idx[1L]]
        parent.label.rare <- parent.labels.rare[idx[1L]]
        parent.label.absorb <- parent.labels.absorb[idx[1L]]

        parents <- strsplit(cell, sep, fixed = TRUE)[[1]]
        drop.idx <- match(parents, refined$feature.ids)
        drop.idx <- drop.idx[!is.na(drop.idx)]

        ## If no parent features match columns (e.g., the lineage is a rare
        ## bucket), do not drop any columns.
        ## Note: x[, -integer(0)] selects *zero* columns, so we must handle this explicitly.
        if (length(drop.idx) == 0L) {
            M.sub <- M[idx, , drop = FALSE]
        } else if (length(drop.idx) >= ncol(M)) {
            ## Dropping all columns would yield an empty matrix; nothing to refine.
            next
        } else {
            M.sub <- M[idx, -drop.idx, drop = FALSE]
        }

        if (nrow(M.sub) == 0L || ncol(M.sub) == 0L) next

        sub.csts <- linf.csts(M.sub,
                             feature.ids = refined$feature.ids[-drop.idx],
                             feature.labels = refined$feature.labels[-drop.idx],
                             n0 = n0,
                             low.freq.policy = low.freq.policy,
                             rare.label = rare.label,
                             backend = backend)

        sub.ids.rare <- sub.csts$cell.id.rare %||% sub.csts$cell.label.rare
        sub.ids.absorb <- sub.csts$cell.id.absorb %||% sub.csts$cell.label.absorb
        sub.labels.rare <- sub.csts$cell.label.rare
        sub.labels.absorb <- sub.csts$cell.label.absorb

        ## If refinement yields no retained child lineages, skip.
        if (all(sub.labels.rare == rare.label)) next

        new.ids.rare[idx] <- paste(parent.id.rare, sub.ids.rare, sep = sep)
        new.ids.absorb[idx] <- paste(parent.id.absorb, sub.ids.absorb, sep = sep)
        new.labels.rare[idx] <- paste(parent.label.rare, sub.labels.rare, sep = sep)
        new.labels.absorb[idx] <- paste(parent.label.absorb, sub.labels.absorb, sep = sep)
    }

    refined$cst.id.levels[[depth + 1L]] <- if (low.freq.policy == "pure") new.ids.rare else new.ids.absorb
    refined$cst.id.levels.rare[[depth + 1L]] <- new.ids.rare
    refined$cst.id.levels.absorb[[depth + 1L]] <- new.ids.absorb
    refined$cst.levels.rare[[depth + 1L]] <- new.labels.rare
    refined$cst.levels.absorb[[depth + 1L]] <- new.labels.absorb

    if (low.freq.policy == "pure") {
        new.labels <- new.labels.rare
    } else {
        new.labels <- new.labels.absorb
    }

    refined$cst.levels[[depth + 1L]] <- new.labels
    refined$cst.depth <- depth + 1L

    refined$cell.id.rare <- new.ids.rare
    refined$cell.id.absorb <- new.ids.absorb
    refined$cell.id <- refined$cst.id.levels[[depth + 1L]]
    refined$cell.label.rare <- new.labels.rare
    refined$cell.label.absorb <- new.labels.absorb
    refined$cell.label <- new.labels

    refined$low.freq.policy <- low.freq.policy
    refined$rare.label <- rare.label
    refined$matrix.backend <- backend
    refined$sep <- sep
    refined$landmarks <- NULL

    class(refined) <- "linf.csts"

    refined
}

#' Switch a dCST hierarchy to the "absorb" view (collapse rare buckets)
#'
#' @description
#' Returns a copy of a \code{"linf.csts"} object with \code{cell.label} (and, if
#' present, \code{cst.levels}) replaced by the precomputed "absorb" labeling.
#' This does not recompute dCSTs; it only switches between labelings already
#' stored in the object.
#'
#' @param csts A \code{"linf.csts"} object produced by \code{\link{linf.csts}}
#'   and optionally refined by \code{\link{refine.linf.csts}} /
#'   \code{\link{refine.linf.csts.iter}}.
#'
#' @return A \code{"linf.csts"} object using the "absorb" view.
#'
#' @export
collapse.rare <- function(csts) {

  validate.linf.csts(csts)

  if (is.null(csts$cell.label.absorb)) {
    stop("collapse.rare: no precomputed absorb labels found in object")
  }

  if (!is.null(csts$cell.index.absorb)) {
    csts$cell.index <- csts$cell.index.absorb
  }
  if (!is.null(csts$cell.id.absorb)) {
    csts$cell.id <- csts$cell.id.absorb
  }
  csts$cell.label <- csts$cell.label.absorb
  csts$low.freq.policy <- "absorb"

  if (!is.null(csts$cst.id.levels)) {
    if (is.null(csts$cst.id.levels.absorb)) {
      depth <- csts$cst.depth
      csts$cst.id.levels[[depth]] <- csts$cell.id %||% csts$cell.label
    } else {
      csts$cst.id.levels <- csts$cst.id.levels.absorb
    }
  }

  if (!is.null(csts$cst.levels)) {
    if (is.null(csts$cst.levels.absorb)) {
      ## Fall back to updating only the leaf level
      depth <- csts$cst.depth
      csts$cst.levels[[depth]] <- csts$cell.label.absorb
    } else {
      csts$cst.levels <- csts$cst.levels.absorb
    }
  }

  csts$landmarks <- NULL
  class(csts) <- "linf.csts"
  csts
}

#' Switch a dCST hierarchy to the "rare" view (explicit rare buckets)
#'
#' @description
#' Returns a copy of a \code{"linf.csts"} object with \code{cell.label} (and, if
#' present, \code{cst.levels}) replaced by the precomputed "rare" labeling.
#' This does not recompute dCSTs; it only switches between labelings already
#' stored in the object. This explicit rare-bucket view is the active labeling
#' used by \code{low.freq.policy = "pure"}.
#'
#' @param csts A \code{"linf.csts"} object produced by \code{\link{linf.csts}}
#'   and optionally refined by \code{\link{refine.linf.csts}} /
#'   \code{\link{refine.linf.csts.iter}}.
#'
#' @return A \code{"linf.csts"} object using the "rare" view.
#'
#' @export
expand.rare <- function(csts) {

  validate.linf.csts(csts)

  if (is.null(csts$cell.label.rare)) {
    stop("expand.rare: no precomputed rare labels found in object")
  }

  if (!is.null(csts$cell.index.rare)) {
    csts$cell.index <- csts$cell.index.rare
  }
  if (!is.null(csts$cell.id.rare)) {
    csts$cell.id <- csts$cell.id.rare
  }
  csts$cell.label <- csts$cell.label.rare
  csts$low.freq.policy <- "pure"

  if (!is.null(csts$cst.id.levels)) {
    if (is.null(csts$cst.id.levels.rare)) {
      depth <- csts$cst.depth
      csts$cst.id.levels[[depth]] <- csts$cell.id %||% csts$cell.label
    } else {
      csts$cst.id.levels <- csts$cst.id.levels.rare
    }
  }

  if (!is.null(csts$cst.levels)) {
    if (is.null(csts$cst.levels.rare)) {
      ## Fall back to updating only the leaf level
      depth <- csts$cst.depth
      csts$cst.levels[[depth]] <- csts$cell.label.rare
    } else {
      csts$cst.levels <- csts$cst.levels.rare
    }
  }

  csts$landmarks <- NULL
  class(csts) <- "linf.csts"
  csts
}

#' Print method for \code{"linf.csts"}
#'
#' @param x A \code{"linf.csts"} object.
#' @param ... Unused.
#' @return The input object, invisibly.
#' @export
print.linf.csts <- function(x, ...) {

  validate.linf.csts(x)

  ## Determine hierarchy
  if (is.null(x$cst.levels)) {
    levels <- list(level1 = x$cell.label)
    max.depth <- 1L
  } else {
    levels <- x$cst.levels
    max.depth <- x$cst.depth
  }

  cat("\n================================================================================\n")
  cat("Dominant Community State Type Hierarchy\n")
  cat("================================================================================\n")
  cat("Total samples: ", length(x$cell.label), "\n")
  cat("Max depth:     ", max.depth, "\n")

  ## Policy stamp (if available)
  if (!is.null(x$low.freq.policy) && !is.null(x$rare.label)) {
    cat("Low-freq policy: ", x$low.freq.policy,
        " (rare.label: ", x$rare.label, ")\n", sep = "")
  }

  cat("--------------------------------------------------------------------------------\n\n")

  for (d in seq_along(levels)) {
    cat("Depth", d, "\n")
    tab <- sort(table(levels[[d]]), decreasing = TRUE)
    for (nm in names(tab)) {
      cat("  ", nm, ": ", tab[[nm]], "\n", sep = "")
    }
    cat("\n")
  }

  invisible(x)
}

#' Summarize dCST hierarchy statistics
#'
#' @param object A \code{"linf.csts"} object.
#' @param ... Unused.
#' @return Data frame with depth-wise statistics
#' @export
summary.linf.csts <- function(object, ...) {

  validate.linf.csts(object)

  ## Determine hierarchy
  if (is.null(object$cst.levels)) {
    levels <- list(level1 = object$cell.label)
  } else {
    levels <- object$cst.levels
  }

  out <- data.frame(
    depth = integer(0),
    n.cells = integer(0),
    total.samples = integer(0),
    mean.size = numeric(0),
    median.size = numeric(0),
    min.size = numeric(0),
    max.size = numeric(0)
  )

  for (d in seq_along(levels)) {
    lbl <- levels[[d]]
    tab <- table(lbl)

    out <- rbind(out, data.frame(
      depth = d,
      n.cells = length(tab),
      total.samples = sum(tab),
      mean.size = mean(tab),
      median.size = stats::median(tab),
      min.size = min(tab),
      max.size = max(tab)
    ))
  }

  out
}

escape.latex <- function(x) {
  x <- gsub("\\\\", "\\\\textbackslash{}", x, fixed = TRUE)
  x <- gsub("([#$%&_{}])", "\\\\\\1", x, perl = TRUE)
  x <- gsub("~", "\\\\textasciitilde{}", x, fixed = TRUE)
  x <- gsub("\\^", "\\\\textasciicircum{}", x, perl = TRUE)
  x
}

df.to.latex <- function(df,
                        caption = NULL,
                        label = NULL,
                        digits = 1,
                        include.rownames = FALSE,
                        booktabs = TRUE,
                        use.float = TRUE,
                        print = FALSE) {
  if (!is.data.frame(df)) stop("df.to.latex: df must be a data.frame")

  if (!include.rownames) {
    row_spec <- "l"
  } else {
    row_spec <- "l"
    df <- cbind(Row = rownames(df), df, stringsAsFactors = FALSE)
  }

  col_spec <- paste0(row_spec, paste(rep("r", ncol(df) - 1L), collapse = ""))
  if (ncol(df) == 1L) col_spec <- "l"

  tab_top <- if (booktabs) "\\toprule" else "\\hline"
  tab_mid <- if (booktabs) "\\midrule" else "\\hline"
  tab_bot <- if (booktabs) "\\bottomrule" else "\\hline"

  lines <- character(0)
  if (use.float) lines <- c(lines, "\\begin{table}[htbp]", "\\centering")
  if (!is.null(caption)) lines <- c(lines, paste0("\\caption{", escape.latex(caption), "}"))
  if (!is.null(label)) lines <- c(lines, paste0("\\label{", escape.latex(label), "}"))

  lines <- c(lines, paste0("\\begin{tabular}{", col_spec, "}"), tab_top)

  header <- paste(escape.latex(colnames(df)), collapse = " & ")
  lines <- c(lines, paste0(header, " \\\\"), tab_mid)

  for (i in seq_len(nrow(df))) {
    vals <- vapply(df[i, , drop = FALSE], function(v) {
      if (is.numeric(v)) {
        format(round(v, digits), trim = TRUE, scientific = FALSE)
      } else {
        as.character(v)
      }
    }, character(1))
    vals <- escape.latex(vals)
    lines <- c(lines, paste0(paste(vals, collapse = " & "), " \\\\"))
  }

  lines <- c(lines, tab_bot, "\\end{tabular}")
  if (use.float) lines <- c(lines, "\\end{table}")

  if (isTRUE(print)) cat(paste(lines, collapse = "\n"), "\n")
  lines
}

#' Render dCSTs as a LaTeX table
#'
#' @description
#' Generates LaTeX code summarizing dCSTs at a specified hierarchy depth.
#' Uses the explicit dCST hierarchy stored in \code{cst.levels} when available.
#'
#' @param csts A dCST object produced by \code{linf.csts()} and optionally refined
#'   by \code{refine.linf.csts()} or \code{refine.linf.csts.iter()}.
#' @param depth Integer. dCST depth to render. Default is the leaf level
#'   (\code{csts$cst.depth}). If \code{cst.levels} is missing, depth is ignored.
#' @param caption Character or NULL. LaTeX table caption.
#' @param label Character or NULL. LaTeX label for referencing the table.
#' @param digits Integer. Digits to display for percentages. Default: 1.
#' @param include.percent Logical. If TRUE, include percent of total samples.
#'
#' @return Character vector containing LaTeX table code.
#'
#' @details
#' This function is hierarchy-aware. It does not infer dCST depth from label
#' strings. If the dCST object does not contain \code{cst.levels}, the function
#' falls back to using \code{cell.label} as a single-level dCST.
#'
#' @export
latex.linf.csts <- function(csts,
                            depth = NULL,
                            caption = NULL,
                            label = NULL,
                            digits = 1,
                            include.percent = TRUE) {

  validate.linf.csts(csts)

  ## Determine labels at requested depth
  if (!is.null(csts$cst.levels)) {
    if (is.null(depth)) {
      depth <- csts$cst.depth
    }
    if (depth < 1 || depth > csts$cst.depth) {
      stop("latex.linf.csts: invalid depth")
    }
    labels <- csts$cst.levels[[depth]]
  } else {
    ## Backward compatibility: flat dCST
    labels <- csts$cell.label
    depth <- 1L
  }

  tab <- sort(table(labels), decreasing = TRUE)
  total <- sum(tab)

  df <- data.frame(
    CST = names(tab),
    Samples = as.integer(tab),
    stringsAsFactors = FALSE
  )

  if (include.percent) {
    df$Percent <- round(100 * df$Samples / total, digits)
  }

  ## Build LaTeX using df.to.latex()
  latex <- df.to.latex(
    df,
    caption = caption,
    label = label,
    digits = digits,
    include.rownames = FALSE,
    booktabs = TRUE,
    use.float = TRUE,
    print = FALSE
  )

  invisible(latex)
}

Try the linf package in your browser

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

linf documentation built on Aug. 5, 2026, 9:08 a.m.