R/surv-utils.R

Defines functions .r4vn_surv_ai_text .r4vn_surv_finegray .r4vn_surv_ph .r4vn_surv_cox_models .r4vn_surv_cox_diagnostics .r4vn_surv_extract_interaction .r4vn_surv_extract_cox .r4vn_surv_fit_cox .r4vn_surv_rhs .r4vn_surv_compare_risk .r4vn_surv_compare_rates .r4vn_surv_rate_table .r4vn_surv_rate_ci .r4vn_surv_events_interval .r4vn_surv_person_time .r4vn_surv_rmst .r4vn_surv_median .r4vn_surv_reverse_followup .r4vn_surv_interpretation .r4vn_surv_auto_times .r4vn_surv_group_overview .r4vn_surv_overview .r4vn_surv_cif_at .r4vn_surv_km_at .r4vn_surv_at_risk .r4vn_surv_step_component .r4vn_surv_step_value .r4vn_surv_curve_cif .r4vn_surv_state_col .r4vn_surv_lifetable .r4vn_surv_curve_km .r4vn_surv_clean_strata .r4vn_surv_ms_formula .r4vn_surv_formula .r4vn_surv_complete .r4vn_surv_validate_time .r4vn_surv_event_status .r4vn_surv_equal .r4vn_surv_default_failure .r4vn_surv_internal_data .r4vn_surv_apply_reference .r4vn_surv_merge_specs .r4vn_surv_as_spec .r4vn_surv_ci_text .r4vn_surv_fmt .r4vn_surv_fmt_p .r4vn_surv_unit_label .r4vn_surv_label .r4vn_surv_name .r4vn_surv_deparse .r4vn_surv_data .r4vn_surv_require

# R4VN survival utilities -------------------------------------------------
# Internal helpers for tabsurv(), cox(), and gsurv().

.r4vn_surv_require <- function() {
  if (!requireNamespace("survival", quietly = TRUE)) {
    stop("Package `survival` is required. Install it with install.packages('survival').", call. = FALSE)
  }
  invisible(TRUE)
}

.r4vn_surv_data <- function(data = NULL) {
  if (is.null(data)) {
    f <- get0(".r4vn_get_active", mode = "function", inherits = TRUE)
    if (!is.null(f)) return(f())
    f <- get0(".r4vn_resolve_analysis_data", mode = "function", inherits = TRUE)
    if (!is.null(f)) return(f(NULL))
    stop("No active data frame. Use `usedf(data)` or supply `data =`.", call. = FALSE)
  }
  if (!is.data.frame(data)) stop("`data` must be a data frame.", call. = FALSE)
  data
}

.r4vn_surv_deparse <- function(x) paste(deparse(x, width.cutoff = 500L), collapse = "")

.r4vn_surv_name <- function(expr, data, arg, allow_null = FALSE) {
  if (identical(expr, quote(NULL)) || identical(expr, quote(expr = ))) {
    if (allow_null) return(NULL)
    stop(sprintf("`%s` is required.", arg), call. = FALSE)
  }
  if (is.symbol(expr)) {
    nm <- as.character(expr)
    if (nm %in% names(data)) return(nm)
  }
  val <- try(eval(expr, envir = parent.frame(2L)), silent = TRUE)
  if (!inherits(val, "try-error") && is.character(val) && length(val) == 1L && val %in% names(data)) return(val)
  txt <- .r4vn_surv_deparse(expr)
  stop(sprintf("`%s` must identify one variable in `data`; `%s` was not found.", arg, txt), call. = FALSE)
}

.r4vn_surv_label <- function(data, name) {
  if (is.null(name)) return(NULL)
  lab <- attr(data[[name]], "label", exact = TRUE)
  if (!is.null(lab) && length(lab) && nzchar(as.character(lab)[1L])) as.character(lab)[1L] else name
}

.r4vn_surv_unit_label <- function(unit, plural = TRUE) {
  if (is.null(unit) || !length(unit) || is.na(unit[1L]) || !nzchar(as.character(unit)[1L])) return("time units")
  z <- tolower(as.character(unit)[1L])
  dict <- c(day = "day", days = "day", month = "month", months = "month", year = "year", years = "year",
            week = "week", weeks = "week", hour = "hour", hours = "hour")
  ans <- if (z %in% names(dict)) unname(dict[z]) else as.character(unit)[1L]
  if (plural) paste0(ans, "s") else ans
}

.r4vn_surv_fmt_p <- function(p, digits = 3L) {
  if (length(p) != 1L || is.na(p) || !is.finite(p)) return(NA_character_)
  cut <- 10^(-digits)
  if (p < cut) return(paste0("<", formatC(cut, format = "f", digits = digits)))
  formatC(p, format = "f", digits = digits)
}

.r4vn_surv_fmt <- function(x, digits = 2L) {
  ifelse(is.na(x) | !is.finite(x), NA_character_, formatC(x, format = "f", digits = digits))
}

.r4vn_surv_ci_text <- function(est, lo, hi, digits = 2L, percent = FALSE) {
  if (percent) {
    est <- est * 100; lo <- lo * 100; hi <- hi * 100
  }
  paste0(.r4vn_surv_fmt(est, digits), " (", .r4vn_surv_fmt(lo, digits), "-", .r4vn_surv_fmt(hi, digits), ")")
}

.r4vn_surv_as_spec <- function(x, data, arg = "vars", allow_null = TRUE) {
  if (is.null(x)) {
    if (allow_null) return(NULL)
    stop(sprintf("`%s` is required.", arg), call. = FALSE)
  }
  if (inherits(x, "r4vn_vars")) {
    resolver <- get0(".r4vn_resolve_vars", mode = "function", inherits = TRUE)
    out <- if (is.null(resolver)) {
      as.data.frame(x, stringsAsFactors = FALSE)
    } else {
      as.data.frame(
        resolver(x, data = data, default_type = "auto", strict = TRUE),
        stringsAsFactors = FALSE
      )
    }
    need <- c("variable", "type", "reference_index")
    if (!all(need %in% names(out))) stop(sprintf("`%s` is not a compatible `vars()` object.", arg), call. = FALSE)
    if (!"specification" %in% names(out)) out$specification <- out$variable
  } else if (is.character(x)) {
    if (!length(x)) return(NULL)
    typ <- vapply(x, function(nm) if (nm %in% names(data) && is.numeric(data[[nm]])) "mean" else "categorical", character(1))
    out <- data.frame(variable = x, type = typ, specification = x,
                      reference_index = ifelse(typ == "categorical", 1L, NA_integer_), stringsAsFactors = FALSE)
  } else {
    stop(sprintf("`%s` must be created using `vars()` or be a character vector of variable names.", arg), call. = FALSE)
  }
  miss <- setdiff(out$variable, names(data))
  if (length(miss)) stop(sprintf("Variables not found in `data`: %s.", paste(miss, collapse = ", ")), call. = FALSE)
  out <- out[!duplicated(out$variable), , drop = FALSE]
  rownames(out) <- NULL
  out
}

.r4vn_surv_merge_specs <- function(...) {
  xs <- Filter(Negate(is.null), list(...))
  if (!length(xs)) return(NULL)
  z <- do.call(rbind, xs)
  z <- z[!duplicated(z$variable), , drop = FALSE]
  rownames(z) <- NULL
  z
}

.r4vn_surv_apply_reference <- function(x, spec_row) {
  if (!identical(spec_row$type, "categorical")) {
    if (!is.numeric(x)) stop(sprintf("`%s` must be numeric because it was specified with c./q./f.", spec_row$variable), call. = FALSE)
    return(as.numeric(x))
  }
  if (is.factor(x)) {
    f <- droplevels(x)
  } else {
    lev <- unique(as.character(x[!is.na(x)]))
    f <- factor(as.character(x), levels = lev)
  }
  idx <- spec_row$reference_index
  if (is.na(idx)) idx <- 1L
  if (idx < 1L || idx > nlevels(f)) {
    stop(sprintf("Reference b%d.%s is invalid because `%s` has %d observed levels.", idx, spec_row$variable, spec_row$variable, nlevels(f)), call. = FALSE)
  }
  stats::relevel(f, ref = levels(f)[idx])
}

.r4vn_surv_internal_data <- function(data, spec = NULL) {
  d <- data.frame(row.names = seq_len(nrow(data)))
  map <- NULL
  if (!is.null(spec) && nrow(spec)) {
    map <- spec
    map$internal <- paste0(".x", seq_len(nrow(map)))
    map$variable_label <- vapply(
      map$variable, function(nm) .r4vn_surv_label(data, nm), character(1)
    )
    for (i in seq_len(nrow(map))) d[[map$internal[i]]] <- .r4vn_surv_apply_reference(data[[map$variable[i]]], map[i, , drop = FALSE])
  }
  list(data = d, map = map)
}

.r4vn_surv_default_failure <- function(x) {
  z <- x[!is.na(x)]
  if (!length(z)) stop("The event variable contains no non-missing values.", call. = FALSE)
  if (is.logical(z)) return(TRUE)
  if (is.factor(x)) {
    lev <- levels(droplevels(x))
    if (length(lev) != 2L) stop("`failure` must be supplied when the event variable has more than two levels.", call. = FALSE)
    return(lev[2L])
  }
  u <- unique(z)
  if (length(u) != 2L) stop("`failure` must be supplied when the event variable does not have exactly two observed values.", call. = FALSE)
  if (is.numeric(u)) {
    if (all(sort(u) == c(0, 1))) return(1)
    return(max(u))
  }
  as.character(u[2L])
}

.r4vn_surv_equal <- function(x, value) {
  if (is.factor(x)) x <- as.character(x)
  if (is.factor(value)) value <- as.character(value)
  if (is.character(x) || is.character(value)) return(as.character(x) %in% as.character(value))
  x %in% value
}

.r4vn_surv_event_status <- function(x, failure = NULL, compete = NULL) {
  if (is.null(failure)) failure <- .r4vn_surv_default_failure(x)
  fail <- .r4vn_surv_equal(x, failure)
  comp <- if (is.null(compete)) rep(FALSE, length(x)) else .r4vn_surv_equal(x, compete)
  fail[is.na(x)] <- NA
  comp[is.na(x)] <- NA
  if (any(fail & comp, na.rm = TRUE)) stop("`failure` and `compete` overlap.", call. = FALSE)

  out01 <- ifelse(is.na(x), NA_integer_, as.integer(fail))
  ms <- rep("censor", length(x))
  ms[fail %in% TRUE] <- "failure"
  if (!is.null(compete)) {
    raw <- if (is.factor(x)) as.character(x) else as.character(x)
    for (v in as.character(compete)) ms[!is.na(raw) & raw == v] <- paste0("compete:", v)
  }
  ms[is.na(x)] <- NA_character_
  lev <- c("censor", "failure", sort(unique(ms[grepl("^compete:", ms)])))
  status_ms <- factor(ms, levels = lev)
  list(event = out01, failure = failure, compete = compete, status_ms = status_ms)
}

.r4vn_surv_validate_time <- function(time, event, start = NULL) {
  if (!is.numeric(time)) stop("`time` must be numeric.", call. = FALSE)
  if (!is.null(start) && !is.numeric(start)) stop("`start` must be numeric.", call. = FALSE)
  if (any(time < 0, na.rm = TRUE)) stop("`time` cannot contain negative values.", call. = FALSE)
  if (!is.null(start)) {
    if (any(start < 0, na.rm = TRUE)) stop("`start` cannot contain negative values.", call. = FALSE)
    bad <- !is.na(start) & !is.na(time) & start >= time
    if (any(bad)) stop("Every non-missing `start` value must be smaller than `time` (stop time).", call. = FALSE)
  }
  if (!any(event == 1L, na.rm = TRUE)) warning("No events of interest were observed.", call. = FALSE)
  invisible(TRUE)
}

.r4vn_surv_complete <- function(d, columns) {
  columns <- unique(columns[!is.na(columns) & nzchar(columns)])
  keep <- stats::complete.cases(d[, columns, drop = FALSE])
  list(data = d[keep, , drop = FALSE], keep = keep, excluded = sum(!keep))
}

.r4vn_surv_formula <- function(start = FALSE, rhs = "1") {
  lhs <- if (isTRUE(start)) "survival::Surv(.start, .time, .event)" else "survival::Surv(.time, .event)"
  stats::as.formula(paste(lhs, "~", rhs), env = parent.frame())
}

.r4vn_surv_ms_formula <- function(start = FALSE, rhs = "1") {
  lhs <- if (isTRUE(start)) "survival::Surv(.start, .time, .status_ms)" else "survival::Surv(.time, .status_ms)"
  stats::as.formula(paste(lhs, "~", rhs), env = parent.frame())
}

.r4vn_surv_clean_strata <- function(x) {
  if (is.null(x)) return(rep("All", 0L))
  z <- as.character(x)
  z <- sub("^\\.by=", "", z)
  z <- sub("^by=", "", z)
  z
}

.r4vn_surv_curve_km <- function(fit) {
  if (is.null(fit$strata)) {
    idxs <- list(All = seq_along(fit$time))
  } else {
    ends <- cumsum(as.integer(fit$strata)); starts <- c(1L, head(ends, -1L) + 1L)
    nms <- .r4vn_surv_clean_strata(names(fit$strata))
    idxs <- Map(seq.int, starts, ends); names(idxs) <- nms
  }
  out <- lapply(names(idxs), function(g) {
    ii <- idxs[[g]]
    se <- if (is.null(fit$std.err)) rep(NA_real_, length(ii)) else fit$std.err[ii]
    data.frame(group = g, time = c(0, fit$time[ii]), estimate = c(1, fit$surv[ii]),
               std.err = c(0, se),
               lower = c(1, fit$lower[ii]), upper = c(1, fit$upper[ii]),
               n.risk = c(if (length(ii)) fit$n.risk[ii][1L] else NA_real_, fit$n.risk[ii]),
               n.event = c(0, fit$n.event[ii]), n.censor = c(0, fit$n.censor[ii]),
               kind = "survival", stringsAsFactors = FALSE)
  })
  do.call(rbind, out)
}

.r4vn_surv_lifetable <- function(curve, competing = FALSE) {
  groups <- unique(curve$group)
  out <- lapply(groups, function(g) {
    z <- curve[curve$group == g, , drop = FALSE]
    # The first row of every stored curve is the artificial time-zero row.
    if (nrow(z) <= 1L) return(NULL)
    z <- z[-1L, , drop = FALSE]
    base <- z[, intersect(c("group", "time", "n.risk", "n.event", "n.censor"),
                          names(z)), drop = FALSE]
    base$conditional_survival <- ifelse(
      is.finite(z$n.risk) & z$n.risk > 0,
      1 - z$n.event / z$n.risk,
      NA_real_
    )
    if (isTRUE(competing)) {
      base$cumulative_incidence <- z$estimate
    } else {
      base$survival <- z$estimate
      base$cumulative_risk <- 1 - z$estimate
    }
    base$std.error <- if ("std.err" %in% names(z)) z$std.err else NA_real_
    base$lower <- z$lower
    base$upper <- z$upper
    base
  })
  out <- Filter(Negate(is.null), out)
  if (!length(out)) return(data.frame())
  ans <- do.call(rbind, out)
  rownames(ans) <- NULL
  ans
}

.r4vn_surv_state_col <- function(fit, state = "failure") {
  cn <- colnames(fit$pstate)
  if (is.null(cn)) stop("Could not identify states in the Aalen-Johansen fit.", call. = FALSE)
  hit <- which(cn == state)
  if (!length(hit)) hit <- grep(paste0("(^|=)", state, "$"), cn)
  if (!length(hit)) hit <- grep(state, cn, fixed = TRUE)
  if (!length(hit)) stop(sprintf("State `%s` was not found in the Aalen-Johansen fit.", state), call. = FALSE)
  hit[1L]
}

.r4vn_surv_curve_cif <- function(fit, state = "failure") {
  j <- .r4vn_surv_state_col(fit, state)
  if (is.null(fit$strata)) {
    idxs <- list(All = seq_along(fit$time))
  } else {
    ends <- cumsum(as.integer(fit$strata)); starts <- c(1L, head(ends, -1L) + 1L)
    nms <- .r4vn_surv_clean_strata(names(fit$strata))
    idxs <- Map(seq.int, starts, ends); names(idxs) <- nms
  }
  out <- lapply(names(idxs), function(g) {
    ii <- idxs[[g]]
    lo <- if (!is.null(fit$lower)) fit$lower[ii, j] else NA_real_
    hi <- if (!is.null(fit$upper)) fit$upper[ii, j] else NA_real_
    se <- if (!is.null(fit$std.err)) {
      if (is.matrix(fit$std.err)) fit$std.err[ii, j] else fit$std.err[ii]
    } else rep(NA_real_, length(ii))
    data.frame(group = g, time = c(0, fit$time[ii]), estimate = c(0, fit$pstate[ii, j]),
               std.err = c(0, se), lower = c(0, lo), upper = c(0, hi),
               n.risk = c(if (length(ii)) fit$n.risk[ii][1L] else NA_real_, fit$n.risk[ii]),
               n.event = c(0, if (is.matrix(fit$n.event)) rowSums(fit$n.event[ii, , drop = FALSE]) else fit$n.event[ii]),
               n.censor = c(0, fit$n.censor[ii]), kind = "cif", stringsAsFactors = FALSE)
  })
  do.call(rbind, out)
}

.r4vn_surv_step_value <- function(curve, times) {
  vapply(times, function(tt) {
    ii <- which(curve$time <= tt)
    if (!length(ii)) return(curve$estimate[1L])
    curve$estimate[max(ii)]
  }, numeric(1))
}

.r4vn_surv_step_component <- function(curve, times, component, default = NA_real_) {
  vapply(times, function(tt) {
    ii <- which(curve$time <= tt)
    if (!length(ii)) return(default)
    curve[[component]][max(ii)]
  }, numeric(1))
}

.r4vn_surv_at_risk <- function(d, times, groups = NULL) {
  if (is.null(groups)) groups <- rep("All", nrow(d))
  lev <- unique(as.character(groups[!is.na(groups)]))
  out <- do.call(rbind, lapply(lev, function(g) {
    z <- d[as.character(groups) == g, , drop = FALSE]
    n <- vapply(times, function(tt) {
      if (".start" %in% names(z)) sum(z$.start <= tt & z$.time >= tt, na.rm = TRUE) else sum(z$.time >= tt, na.rm = TRUE)
    }, integer(1))
    data.frame(group = g, time = times, n.risk = n, stringsAsFactors = FALSE)
  }))
  rownames(out) <- NULL
  out
}

.r4vn_surv_km_at <- function(fit, times) {
  s <- summary(fit, times = times, extend = TRUE, data.frame = TRUE)
  if (!nrow(s)) return(data.frame())
  if (!"strata" %in% names(s)) s$strata <- "All"
  s$group <- .r4vn_surv_clean_strata(s$strata)
  s[, intersect(c("group", "time", "n.risk", "n.event", "n.censor", "surv", "std.err", "lower", "upper"), names(s)), drop = FALSE]
}

.r4vn_surv_cif_at <- function(curve, d, times) {
  groups <- unique(curve$group)
  risk <- .r4vn_surv_at_risk(d, times, if (".by" %in% names(d)) d$.by else NULL)
  out <- do.call(rbind, lapply(groups, function(g) {
    z <- curve[curve$group == g, , drop = FALSE]
    data.frame(group = g, time = times,
               cif = .r4vn_surv_step_value(z, times),
               std.err = .r4vn_surv_step_component(z, times, "std.err", NA_real_),
               lower = .r4vn_surv_step_component(z, times, "lower", 0),
               upper = .r4vn_surv_step_component(z, times, "upper", 0), stringsAsFactors = FALSE)
  }))
  merge(out, risk, by = c("group", "time"), all.x = TRUE, sort = FALSE)
}

.r4vn_surv_overview <- function(d, event_label = "Event") {
  n <- nrow(d)
  ev <- sum(d$.event == 1L, na.rm = TRUE)
  cens <- n - ev
  pt <- if (".start" %in% names(d)) sum(d$.time - d$.start, na.rm = TRUE) else sum(d$.time, na.rm = TRUE)
  data.frame(N = n, Events = ev, Censored = cens, `Event percent` = if (n) ev / n * 100 else NA_real_,
             `Person-time` = pt, check.names = FALSE)
}

.r4vn_surv_group_overview <- function(d) {
  if (!".by" %in% names(d)) return(NULL)
  groups <- levels(droplevels(d$.by))
  out <- lapply(groups, function(g) {
    z <- d[!is.na(d$.by) & d$.by == g, , drop = FALSE]
    ov <- .r4vn_surv_overview(z)
    data.frame(group = g, ov, stringsAsFactors = FALSE, check.names = FALSE)
  })
  do.call(rbind, out)
}

.r4vn_surv_auto_times <- function(time, n = 3L) {
  x <- as.numeric(time)
  x <- x[is.finite(x) & x > 0]
  if (!length(x)) return(NULL)
  probs <- seq(0, 1, length.out = n + 2L)[-c(1L, n + 2L)]
  ans <- as.numeric(stats::quantile(x, probs = probs, na.rm = TRUE, names = FALSE, type = 2))
  mx <- max(x)
  digits <- if (mx >= 20) 0L else if (mx >= 2) 1L else 2L
  ans <- sort(unique(round(ans, digits = digits)))
  ans[is.finite(ans) & ans > 0]
}

.r4vn_surv_interpretation <- function(x, alpha = 0.05) {
  rows <- list()
  add <- function(section, text) {
    if (!is.null(text) && length(text) && !is.na(text[1L]) && nzchar(text[1L])) {
      rows[[length(rows) + 1L]] <<- data.frame(
        section = section, interpretation = as.character(text[1L]),
        stringsAsFactors = FALSE
      )
    }
  }
  ov <- x$overview
  unit <- .r4vn_surv_unit_label(x$metadata$unit, TRUE)
  add("Overview", sprintf(
    "The analysis included %d observations, %d events (%.1f%%), and %d censored observations over %.2f person-%s.",
    ov$N[1L], ov$Events[1L], ov$`Event percent`[1L], ov$Censored[1L],
    ov$`Person-time`[1L], unit
  ))
  if (!is.null(x$followup) && nrow(x$followup) && is.finite(x$followup$median[1L])) {
    add("Follow-up", sprintf(
      "The reverse Kaplan-Meier median follow-up was %s %s.",
      .r4vn_surv_ci_text(x$followup$median[1L], x$followup$lower[1L],
                         x$followup$upper[1L], 2), unit
    ))
  }
  if (!is.null(x$logrank) && nrow(x$logrank) && is.finite(x$logrank$p[1L])) {
    add("Group comparison", sprintf(
      "The survival curves %s statistically different by the log-rank test (p %s).",
      if (x$logrank$p[1L] < alpha) "were" else "were not",
      .r4vn_surv_fmt_p(x$logrank$p[1L], 3)
    ))
  }
  if (!is.null(x$risk_compare) && nrow(x$risk_compare)) {
    z <- x$risk_compare[which.max(x$risk_compare$time), , drop = FALSE]
    parts <- character()
    if ("rr" %in% names(z) && is.finite(z$rr[1L])) {
      parts <- c(parts, paste0(
        "RR ", .r4vn_surv_ci_text(z$rr[1L], z$rr_lower[1L], z$rr_upper[1L], 2)
      ))
    }
    if ("rd" %in% names(z) && is.finite(z$rd[1L])) {
      parts <- c(parts, paste0(
        "RD ", .r4vn_surv_ci_text(z$rd[1L], z$rd_lower[1L], z$rd_upper[1L], 3)
      ))
    }
    if (length(parts)) add("Risk comparison", sprintf(
      "At time %.2f, %s versus %s: %s.", z$time[1L], z$comparison[1L],
      z$reference[1L], paste(parts, collapse = "; ")
    ))
  }
  if (!is.null(x$irr) && nrow(x$irr)) {
    z <- x$irr[if ("Overall" %in% x$irr$interval) which(x$irr$interval == "Overall")[1L] else 1L, , drop = FALSE]
    if (is.finite(z$irr[1L])) add("Incidence-rate comparison", sprintf(
      "%s versus %s: IRR %s, p %s.", z$comparison[1L], z$reference[1L],
      .r4vn_surv_ci_text(z$irr[1L], z$lower[1L], z$upper[1L], 2),
      .r4vn_surv_fmt_p(z$p[1L], 3)
    ))
  }
  model <- if (!is.null(x$cox$multi) && nrow(x$cox$multi)) x$cox$multi else x$cox$crude
  if (!is.null(model) && nrow(model)) {
    sig <- model[!model$reference & is.finite(model$p) & model$p < alpha, , drop = FALSE]
    if (nrow(sig)) {
      txt <- vapply(seq_len(nrow(sig)), function(i) sprintf(
        "%s (%s): HR %s, p %s",
        if ("variable_label" %in% names(sig)) sig$variable_label[i] else sig$variable[i],
        sig$level[i],
        .r4vn_surv_ci_text(sig$estimate[i], sig$lower[i], sig$upper[i], 2),
        .r4vn_surv_fmt_p(sig$p[i], 3)
      ), character(1))
      add("Cox model", paste0(
        "Statistically significant associations were observed for ",
        paste(txt, collapse = "; "),
        ". Hazard ratios describe associations and should not be interpreted as causal effects without an appropriate study design."
      ))
    } else {
      add("Cox model", "No modeled predictor had a two-sided p-value below 0.05 in the displayed Cox model.")
    }
  }
  if (!is.null(x$finegray$table) && nrow(x$finegray$table)) {
    fg <- x$finegray$table
    sig <- fg[!fg$reference & is.finite(fg$p) & fg$p < alpha, , drop = FALSE]
    if (nrow(sig)) {
      txt <- vapply(seq_len(nrow(sig)), function(i) sprintf(
        "%s (%s): SHR %s, p %s",
        if ("variable_label" %in% names(sig)) sig$variable_label[i] else sig$variable[i],
        sig$level[i],
        .r4vn_surv_ci_text(sig$estimate[i], sig$lower[i], sig$upper[i], 2),
        .r4vn_surv_fmt_p(sig$p[i], 3)
      ), character(1))
      add("Fine-Gray model", paste(txt, collapse = "; "))
    }
  }
  if (!is.null(x$ph) && nrow(x$ph) && "term" %in% names(x$ph) && "p" %in% names(x$ph)) {
    gi <- which(toupper(as.character(x$ph$term)) == "GLOBAL")
    if (length(gi) && is.finite(x$ph$p[gi[1L]])) {
      add("Model diagnostics", sprintf(
        "The global proportional-hazards test %s evidence against the PH assumption (p %s).",
        if (x$ph$p[gi[1L]] < alpha) "showed" else "did not show",
        .r4vn_surv_fmt_p(x$ph$p[gi[1L]], 3)
      ))
    }
  }
  if (isTRUE(x$metadata$competing)) {
    add("Competing risks", "Cumulative incidence was estimated with the Aalen-Johansen method; cause-specific Kaplan-Meier risk was not substituted for the competing-risk estimate.")
  }
  if (!is.null(x$rmst$difference) && nrow(x$rmst$difference)) {
    z <- x$rmst$difference[1L, , drop = FALSE]
    add("RMST comparison", sprintf(
      "%s minus %s: restricted mean survival-time difference %s, p %s.",
      z$comparison[1L], z$reference[1L],
      .r4vn_surv_ci_text(z$difference[1L], z$lower[1L], z$upper[1L], 2),
      .r4vn_surv_fmt_p(z$p[1L], 3)
    ))
  }
  if (!length(rows)) return(NULL)
  do.call(rbind, rows)
}

.r4vn_surv_reverse_followup <- function(d, ci = .95) {
  if (".start" %in% names(d)) return(NULL)
  fit <- survival::survfit(survival::Surv(.time, 1L - .event) ~ 1, data = d, conf.int = ci)
  tb <- summary(fit)$table
  if (is.matrix(tb)) tb <- tb[1L, ]
  getc <- function(pattern) {
    hit <- grep(pattern, names(tb), ignore.case = TRUE)
    if (length(hit)) unname(tb[hit[1L]]) else NA_real_
  }
  data.frame(median = getc("^median$"), lower = getc("LCL"), upper = getc("UCL"), stringsAsFactors = FALSE)
}

.r4vn_surv_median <- function(fit) {
  tb <- summary(fit)$table
  if (is.null(dim(tb))) tb <- matrix(tb, nrow = 1L, dimnames = list("All", names(tb)))
  cn <- colnames(tb)
  med <- grep("^median$", cn, ignore.case = TRUE)
  lo <- grep("LCL", cn, ignore.case = TRUE)
  hi <- grep("UCL", cn, ignore.case = TRUE)
  groups <- rownames(tb); if (is.null(groups)) groups <- "All"
  groups <- .r4vn_surv_clean_strata(groups)
  data.frame(group = groups,
             median = if (length(med)) tb[, med[1L]] else NA_real_,
             lower = if (length(lo)) tb[, lo[1L]] else NA_real_,
             upper = if (length(hi)) tb[, hi[1L]] else NA_real_, stringsAsFactors = FALSE)
}

.r4vn_surv_rmst <- function(fit, tau, ci = .95) {
  tb <- summary(fit, rmean = tau)$table
  if (is.null(dim(tb))) tb <- matrix(tb, nrow = 1L, dimnames = list("All", names(tb)))
  cn <- colnames(tb)
  im <- grep("rmean", cn, ignore.case = TRUE)
  ise <- grep("se\\(rmean\\)|se.*rmean", cn, ignore.case = TRUE)
  if (!length(im)) stop("RMST could not be extracted from `survfit`.", call. = FALSE)
  if (length(im) > 1L && length(ise)) im <- setdiff(im, ise)
  est <- tb[, im[1L]]
  se <- if (length(ise)) tb[, ise[1L]] else rep(NA_real_, length(est))
  z <- stats::qnorm(1 - (1 - ci) / 2)
  groups <- rownames(tb); if (is.null(groups)) groups <- "All"
  groups <- .r4vn_surv_clean_strata(groups)
  out <- data.frame(group = groups, tau = tau, rmst = est, se = se,
                    lower = est - z * se, upper = est + z * se, stringsAsFactors = FALSE)
  if (nrow(out) == 2L && all(is.finite(out$se))) {
    diff <- out$rmst[2L] - out$rmst[1L]
    sed <- sqrt(sum(out$se^2))
    out_diff <- data.frame(reference = out$group[1L], comparison = out$group[2L], difference = diff,
                           lower = diff - z * sed, upper = diff + z * sed,
                           p = 2 * stats::pnorm(-abs(diff / sed)), stringsAsFactors = FALSE)
  } else out_diff <- NULL
  list(table = out, difference = out_diff)
}

.r4vn_surv_person_time <- function(d, a = -Inf, b = Inf) {
  st <- if (".start" %in% names(d)) d$.start else rep(0, nrow(d))
  left <- pmax(st, a)
  right <- pmin(d$.time, b)
  sum(pmax(0, right - left), na.rm = TRUE)
}

.r4vn_surv_events_interval <- function(d, a = -Inf, b = Inf) {
  sum(d$.event == 1L & d$.time > a & d$.time <= b, na.rm = TRUE)
}

.r4vn_surv_rate_ci <- function(events, pt, scale = 100, ci = .95) {
  if (!is.finite(pt) || pt <= 0) return(c(rate = NA_real_, lower = NA_real_, upper = NA_real_))
  alpha <- 1 - ci
  rate <- events / pt * scale
  lo <- if (events == 0) 0 else 0.5 * stats::qchisq(alpha / 2, 2 * events) / pt * scale
  hi <- 0.5 * stats::qchisq(1 - alpha / 2, 2 * (events + 1)) / pt * scale
  c(rate = rate, lower = lo, upper = hi)
}

.r4vn_surv_rate_table <- function(d, at = NULL, mode = TRUE, scale = 100, ci = .95) {
  groups <- if (".by" %in% names(d)) as.character(d$.by) else rep("All", nrow(d))
  lev <- unique(groups[!is.na(groups)])
  mode2 <- if (is.character(mode)) tolower(mode[1L]) else "cumulative"
  out <- list(); k <- 0L
  for (g in lev) {
    z <- d[groups == g, , drop = FALSE]
    if (identical(mode2, "overall") || is.null(at) || !length(at)) {
      ranges <- list(c(-Inf, Inf)); labels <- "Overall"
    } else if (identical(mode2, "interval")) {
      edges <- c(0, sort(unique(at)))
      ranges <- lapply(seq_len(length(edges) - 1L), function(i) c(edges[i], edges[i + 1L]))
      labels <- paste0(edges[-length(edges)], "-", edges[-1L])
    } else if (identical(mode2, "all")) {
      aa <- sort(unique(at))
      edges <- c(0, aa)
      ranges <- c(
        list(c(-Inf, Inf)),
        lapply(aa, function(b) c(-Inf, b)),
        lapply(seq_len(length(edges) - 1L), function(i) c(edges[i], edges[i + 1L]))
      )
      labels <- c(
        "Overall", paste0("0-", aa),
        paste0(edges[-length(edges)], "-", edges[-1L], " (interval)")
      )
    } else {
      aa <- sort(unique(at)); ranges <- lapply(aa, function(b) c(-Inf, b)); labels <- paste0("0-", aa)
    }
    for (i in seq_along(ranges)) {
      a <- ranges[[i]][1L]; b <- ranges[[i]][2L]
      pt <- .r4vn_surv_person_time(z, a, b)
      ev <- .r4vn_surv_events_interval(z, a, b)
      rr <- .r4vn_surv_rate_ci(ev, pt, scale, ci)
      k <- k + 1L
      out[[k]] <- data.frame(group = g, interval = labels[i], start = if (is.finite(a)) a else NA_real_, end = if (is.finite(b)) b else NA_real_,
                             events = ev, person_time = pt, rate = rr["rate"], lower = rr["lower"], upper = rr["upper"], scale = scale,
                             stringsAsFactors = FALSE)
    }
  }
  do.call(rbind, out)
}

.r4vn_surv_compare_rates <- function(rate_table, ci = .95) {
  groups <- unique(rate_table$group)
  if (length(groups) != 2L) stop("`irr = TRUE` requires exactly two groups in `by`.", call. = FALSE)
  ints <- unique(rate_table$interval)
  out <- lapply(ints, function(intv) {
    z <- rate_table[rate_table$interval == intv, , drop = FALSE]
    z <- z[match(groups, z$group), , drop = FALSE]
    if (nrow(z) != 2L || any(z$person_time <= 0)) return(NULL)
    tst <- try(stats::poisson.test(z$events[c(2, 1)], T = z$person_time[c(2, 1)], conf.level = ci), silent = TRUE)
    if (inherits(tst, "try-error")) return(data.frame(interval = intv, reference = groups[1], comparison = groups[2], irr = NA_real_, lower = NA_real_, upper = NA_real_, p = NA_real_))
    est <- if (length(tst$estimate)) unname(tst$estimate[1L]) else (z$events[2] / z$person_time[2]) / (z$events[1] / z$person_time[1])
    data.frame(interval = intv, reference = groups[1], comparison = groups[2], irr = est,
               lower = tst$conf.int[1L], upper = tst$conf.int[2L], p = tst$p.value, stringsAsFactors = FALSE)
  })
  do.call(rbind, Filter(Negate(is.null), out))
}

.r4vn_surv_compare_risk <- function(risk_table, ci = .95, rr = FALSE, rd = FALSE) {
  groups <- unique(risk_table$group)
  if (length(groups) != 2L) stop("Risk comparisons require exactly two groups in `by`.", call. = FALSE)
  times <- unique(risk_table$time)
  zcrit <- stats::qnorm(1 - (1 - ci) / 2)
  out <- lapply(times, function(tt) {
    z <- risk_table[risk_table$time == tt, , drop = FALSE]
    z <- z[match(groups, z$group), , drop = FALSE]
    if (nrow(z) != 2L) return(NULL)
    r0 <- z$risk[1L]; r1 <- z$risk[2L]
    se0 <- z$se[1L]; se1 <- z$se[2L]
    ans <- data.frame(time = tt, reference = groups[1L], comparison = groups[2L], stringsAsFactors = FALSE)
    if (rr) {
      if (all(c(r0, r1, se0, se1) > 0, na.rm = FALSE)) {
        lrr <- log(r1 / r0); sel <- sqrt((se1 / r1)^2 + (se0 / r0)^2)
        ans$rr <- exp(lrr); ans$rr_lower <- exp(lrr - zcrit * sel); ans$rr_upper <- exp(lrr + zcrit * sel); ans$rr_p <- 2 * stats::pnorm(-abs(lrr / sel))
      } else ans[c("rr", "rr_lower", "rr_upper", "rr_p")] <- NA_real_
    }
    if (rd) {
      dif <- r1 - r0; sed <- sqrt(se1^2 + se0^2)
      ans$rd <- dif; ans$rd_lower <- dif - zcrit * sed; ans$rd_upper <- dif + zcrit * sed; ans$rd_p <- 2 * stats::pnorm(-abs(dif / sed))
    }
    ans
  })
  do.call(rbind, Filter(Negate(is.null), out))
}

.r4vn_surv_rhs <- function(map, extra = character(), interaction = NULL) {
  terms <- if (is.null(map) || !nrow(map)) character() else map$internal
  terms <- c(terms, extra)
  if (!is.null(interaction) && length(interaction) == 2L) terms <- c(terms, paste(interaction, collapse = "*"))
  if (!length(terms)) "1" else paste(unique(terms), collapse = " + ")
}

.r4vn_surv_fit_cox <- function(d, map, start = FALSE, strata_name = NULL, cluster_name = NULL,
                               frailty_name = NULL, interaction_internal = NULL, ties = "efron") {
  extra <- character()
  if (!is.null(strata_name)) extra <- c(extra, paste0("strata(", strata_name, ")"))
  if (!is.null(cluster_name)) extra <- c(extra, paste0("cluster(", cluster_name, ")"))
  if (!is.null(frailty_name)) extra <- c(extra, paste0("frailty(", frailty_name, ")"))
  rhs <- .r4vn_surv_rhs(map, extra, interaction_internal)
  f <- .r4vn_surv_formula(start, rhs)
  fit <- survival::coxph(f, data = d, ties = ties, x = TRUE, y = TRUE, model = TRUE)

  # Preserve the user-facing predictor names for postestimation. tabsurv() fits
  # Cox models on collision-safe internal columns (.x1, .x2, ...), but margins()
  # and predict() should accept the original variables (age, treatment, ...).
  if (!is.null(map) && nrow(map)) {
    pred <- d[, map$internal, drop = FALSE]
    names(pred) <- map$variable
    used_names <- rownames(fit$model)
    hit <- match(used_names, rownames(d))
    if (length(hit) && all(!is.na(hit))) pred <- pred[hit, , drop = FALSE]
    fit$.r4vn_prediction_data <- pred
    fit$.r4vn_prediction_map <- map[, intersect(c("variable", "internal", "type", "reference_index"), names(map)), drop = FALSE]
  }
  fit
}

.r4vn_surv_extract_cox <- function(fit, map, ci = .95) {
  if (is.null(map) || !nrow(map)) return(data.frame())
  sm <- summary(fit, conf.int = ci)
  cf <- sm$coefficients; cc <- sm$conf.int
  if (is.null(dim(cf))) cf <- matrix(cf, nrow = 1L, dimnames = list(names(stats::coef(fit))[1L], names(cf)))
  if (is.null(dim(cc))) cc <- matrix(cc, nrow = 1L, dimnames = list(rownames(cf)[1L], names(cc)))
  rn <- rownames(cf)
  pcol <- grep("Pr\\(", colnames(cf)); if (!length(pcol)) pcol <- ncol(cf)
  out <- list(); k <- 0L
  for (i in seq_len(nrow(map))) {
    sp <- map[i, , drop = FALSE]; internal <- sp$internal
    if (sp$type != "categorical") {
      hit <- which(rn == internal)
      k <- k + 1L
      if (!length(hit)) {
        out[[k]] <- data.frame(variable = sp$variable, level = "Per 1 unit", reference = FALSE,
                               estimate = NA_real_, lower = NA_real_, upper = NA_real_, p = NA_real_, stringsAsFactors = FALSE)
      } else {
        j <- hit[1L]
        out[[k]] <- data.frame(variable = sp$variable, level = "Per 1 unit", reference = FALSE,
                               estimate = cc[j, "exp(coef)"], lower = cc[j, grep("lower", colnames(cc), ignore.case = TRUE)[1L]],
                               upper = cc[j, grep("upper", colnames(cc), ignore.case = TRUE)[1L]], p = cf[j, pcol[1L]], stringsAsFactors = FALSE)
      }
    } else {
      f <- fit$model[[internal]]
      lev <- levels(f)
      coef_idx <- grep(paste0("^", internal), rn)
      nonref <- lev[-1L]
      for (j in seq_along(lev)) {
        k <- k + 1L
        if (j == 1L) {
          out[[k]] <- data.frame(variable = sp$variable, level = lev[j], reference = TRUE,
                                 estimate = 1, lower = 1, upper = 1, p = NA_real_, stringsAsFactors = FALSE)
        } else {
          pos <- j - 1L
          rowj <- if (pos <= length(coef_idx)) coef_idx[pos] else NA_integer_
          if (is.na(rowj)) {
            out[[k]] <- data.frame(variable = sp$variable, level = lev[j], reference = FALSE,
                                   estimate = NA_real_, lower = NA_real_, upper = NA_real_, p = NA_real_, stringsAsFactors = FALSE)
          } else {
            out[[k]] <- data.frame(variable = sp$variable, level = lev[j], reference = FALSE,
                                   estimate = cc[rowj, "exp(coef)"], lower = cc[rowj, grep("lower", colnames(cc), ignore.case = TRUE)[1L]],
                                   upper = cc[rowj, grep("upper", colnames(cc), ignore.case = TRUE)[1L]], p = cf[rowj, pcol[1L]], stringsAsFactors = FALSE)
          }
        }
      }
    }
  }
  ans <- do.call(rbind, out)
  labels <- stats::setNames(map$variable_label, map$variable)
  ans$variable_label <- unname(labels[ans$variable])
  ans[, c("variable", "variable_label", setdiff(names(ans), c("variable", "variable_label"))), drop = FALSE]
}


.r4vn_surv_extract_interaction <- function(fit, map, ci = .95) {
  if (is.null(fit)) return(NULL)
  sm <- summary(fit, conf.int = ci)
  cf <- sm$coefficients; cc <- sm$conf.int
  if (is.null(dim(cf))) cf <- matrix(cf, nrow = 1L, dimnames = list(names(stats::coef(fit))[1L], names(cf)))
  if (is.null(dim(cc))) cc <- matrix(cc, nrow = 1L, dimnames = list(rownames(cf)[1L], names(cc)))
  rn <- rownames(cf)
  hit <- grep(":", rn, fixed = TRUE)
  if (!length(hit)) return(NULL)
  pcol <- grep("Pr\\(", colnames(cf)); if (!length(pcol)) pcol <- ncol(cf)
  lo_col <- grep("lower", colnames(cc), ignore.case = TRUE)[1L]
  hi_col <- grep("upper", colnames(cc), ignore.case = TRUE)[1L]
  labels <- rn[hit]
  if (!is.null(map) && nrow(map)) {
    ord <- order(nchar(map$internal), decreasing = TRUE)
    for (i in ord) labels <- gsub(map$internal[i], map$variable[i], labels, fixed = TRUE)
  }
  data.frame(term = labels,
             estimate = cc[hit, "exp(coef)"], lower = cc[hit, lo_col], upper = cc[hit, hi_col],
             p = cf[hit, pcol[1L]], stringsAsFactors = FALSE)
}

.r4vn_surv_cox_diagnostics <- function(fit) {
  if (is.null(fit)) return(NULL)
  sm <- summary(fit)
  pulltest <- function(z, prefix) {
    if (is.null(z) || !length(z)) return(c(statistic = NA_real_, df = NA_real_, p = NA_real_))
    nm <- names(z)
    stat <- if ("test" %in% nm) z[["test"]] else z[1L]
    df <- if ("df" %in% nm) z[["df"]] else if (length(z) >= 2L) z[2L] else NA_real_
    pv <- if ("pvalue" %in% nm) z[["pvalue"]] else if (length(z) >= 3L) z[3L] else NA_real_
    c(statistic = unname(stat), df = unname(df), p = unname(pv))
  }
  lr <- pulltest(sm$logtest, "LR")
  wa <- pulltest(sm$waldtest, "Wald")
  sc <- pulltest(sm$sctest, "Score")
  conc <- sm$concordance
  data.frame(
    n = if (!is.null(fit$n)) fit$n else NA_real_,
    events = if (!is.null(fit$nevent)) fit$nevent else NA_real_,
    concordance = if (length(conc)) unname(conc[1L]) else NA_real_,
    concordance_se = if (length(conc) >= 2L) unname(conc[2L]) else NA_real_,
    AIC = tryCatch(stats::AIC(fit), error = function(e) NA_real_),
    LR_chisq = lr["statistic"], LR_df = lr["df"], LR_p = lr["p"],
    Wald_chisq = wa["statistic"], Wald_df = wa["df"], Wald_p = wa["p"],
    Score_chisq = sc["statistic"], Score_df = sc["df"], Score_p = sc["p"],
    stringsAsFactors = FALSE, check.names = FALSE
  )
}

.r4vn_surv_cox_models <- function(d, focal_map, all_map, adjusted_spec = NULL, multi_spec = NULL,
                                  start = FALSE, strata_name = NULL, cluster_name = NULL, frailty_name = NULL,
                                  ci = .95, ties = "efron", interaction_vars = NULL) {
  crude <- adjusted <- multi <- NULL
  crude_fits <- adjusted_fits <- list()
  interaction_table <- diagnostics <- NULL

  if (!is.null(focal_map) && nrow(focal_map)) {
    crude <- do.call(rbind, lapply(seq_len(nrow(focal_map)), function(i) {
      mp <- focal_map[i, , drop = FALSE]
      fit <- .r4vn_surv_fit_cox(d, mp, start, strata_name, cluster_name, frailty_name, ties = ties)
      crude_fits[[mp$variable]] <<- fit
      .r4vn_surv_extract_cox(fit, mp, ci)
    }))
  }

  if (!is.null(adjusted_spec)) {
    adjust_all <- isTRUE(adjusted_spec)
    if (adjust_all) adjvars <- focal_map$variable else adjvars <- adjusted_spec$variable
    adjusted <- do.call(rbind, lapply(seq_len(nrow(focal_map)), function(i) {
      focal <- focal_map[i, , drop = FALSE]
      covars <- setdiff(adjvars, focal$variable)
      use <- unique(c(focal$variable, covars))
      mp <- all_map[match(use, all_map$variable), , drop = FALSE]
      mp <- mp[!is.na(mp$variable), , drop = FALSE]
      fit <- .r4vn_surv_fit_cox(d, mp, start, strata_name, cluster_name, frailty_name, ties = ties)
      adjusted_fits[[focal$variable]] <<- fit
      .r4vn_surv_extract_cox(fit, focal, ci)
    }))
  }

  multi_fit <- NULL
  if (!is.null(multi_spec)) {
    if (isTRUE(multi_spec)) mp <- focal_map else mp <- all_map[all_map$variable %in% multi_spec$variable, , drop = FALSE]
    interaction_internal <- NULL
    if (!is.null(interaction_vars) && length(interaction_vars) == 2L) {
      interaction_internal <- mp$internal[match(interaction_vars, mp$variable)]
      if (anyNA(interaction_internal)) stop("Both interaction variables must be present in the multivariable model.", call. = FALSE)
    }
    multi_fit <- .r4vn_surv_fit_cox(d, mp, start, strata_name, cluster_name, frailty_name, interaction_internal, ties)
    multi <- .r4vn_surv_extract_cox(multi_fit, mp, ci)
    interaction_table <- .r4vn_surv_extract_interaction(multi_fit, mp, ci)
    diagnostics <- .r4vn_surv_cox_diagnostics(multi_fit)
  }

  list(crude = crude, adjusted = adjusted, multi = multi, interaction = interaction_table,
       diagnostics = diagnostics, crude_fits = crude_fits,
       adjusted_fits = adjusted_fits, multi_fit = multi_fit)
}

.r4vn_surv_ph <- function(fit) {
  if (is.null(fit)) return(NULL)
  z <- survival::cox.zph(fit, terms = TRUE, global = TRUE)
  tb <- as.data.frame(z$table)
  tb$term <- rownames(tb); rownames(tb) <- NULL
  names(tb)[names(tb) == "p"] <- "p"
  tb[, c("term", setdiff(names(tb), "term")), drop = FALSE]
}

.r4vn_surv_finegray <- function(d, map, start = FALSE, ci = .95, ties = "efron") {
  if (is.null(map) || !nrow(map)) stop("`finegray = TRUE` requires `vars` or `multi` covariates.", call. = FALSE)
  d$.fgid <- if (".id" %in% names(d)) d$.id else seq_len(nrow(d))
  lhs <- if (start) "survival::Surv(.start, .time, .status_ms)" else "survival::Surv(.time, .status_ms)"
  rhs <- paste(c(map$internal, ".fgid"), collapse = " + ")
  fgform <- stats::as.formula(paste(lhs, "~", rhs))
  fg <- survival::finegray(fgform, data = d, etype = "failure", id = if (start) d$.fgid else NULL)
  fitform <- stats::as.formula(paste("survival::Surv(fgstart, fgstop, fgstatus) ~", paste(map$internal, collapse = " + "), "+ cluster(.fgid)"))
  fit <- survival::coxph(fitform, data = fg, weights = fg[["fgwt"]], ties = ties, x = TRUE, y = TRUE, model = TRUE)
  list(fit = fit, table = .r4vn_surv_extract_cox(fit, map, ci), data = fg)
}

.r4vn_surv_ai_text <- function(x, digits = 2L, p_digits = 3L) {
  lines <- c(
    sprintf("Survival analysis: N=%d, events=%d, censored=%d.", x$overview$N[1L], x$overview$Events[1L], x$overview$Censored[1L])
  )
  if (!is.null(x$followup) && nrow(x$followup)) lines <- c(lines, sprintf("Median follow-up: %s.", .r4vn_surv_ci_text(x$followup$median[1], x$followup$lower[1], x$followup$upper[1], digits)))
  if (!is.null(x$logrank)) lines <- c(lines, sprintf("Log-rank p=%s.", .r4vn_surv_fmt_p(x$logrank$p[1], p_digits)))
  if (!is.null(x$cox$multi) && nrow(x$cox$multi)) {
    zz <- x$cox$multi[!x$cox$multi$reference, , drop = FALSE]
    lines <- c(lines, apply(zz, 1, function(r) sprintf("%s %s: HR %s, p=%s.", r[["variable"]], r[["level"]],
      .r4vn_surv_ci_text(as.numeric(r[["estimate"]]), as.numeric(r[["lower"]]), as.numeric(r[["upper"]]), digits),
      .r4vn_surv_fmt_p(as.numeric(r[["p"]]), p_digits))))
  }
  paste(lines, collapse = "\n")
}

Try the R4VN package in your browser

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

R4VN documentation built on Sept. 30, 2026, 5:13 p.m.