Benchmarking & Validation

This guide consolidates all benchmarking, robustness, and validation evidence generated for MetaOmixTools during development and peer-review revision. It is intended as a permanent, citable reference so that users understand why the implemented methods were chosen, how robust they are to real-world heterogeneous data, how concordant the alternative algorithms are with one another, and how fast the platform runs under both typical and extreme conditions.

Four independent studies are presented:

  1. Algorithm landscape & library selection — A comparative survey of available R packages for consensus rank meta-analysis, justifying the engines shipped in MetaRank.
  2. Robustness to missing features and heterogeneous overlap — A systematic sensitivity analysis built around the suite’s NA management and overlap-threshold architecture.
  3. Method concordance (Jaccard similarity) — Pairwise agreement between every meta-analysis method, both within and across libraries, for MetaRank and MetaEnrich.
  4. Execution time & stress testing — Runtime characterization under standard inputs and a worst-case 80,000-gene scenario.
NoteCommon Test Corpus

Unless stated otherwise, all studies use the same four independent lung cancer transcriptomic datasets: GSE10072, GSE19188, GSE63459, and GSE75037. These are shipped with the suite as the built-in Example Data, deliberately retaining duplicates, missing values, and mixed formats so that the suite’s parsing and harmonization logic can be demonstrated on genuinely messy input. Dataset sizes range from approximately 22,000 to 55,000 features per study.

1 Algorithm landscape and library selection

Before committing to a meta-analysis engine, the complete ecosystem of R packages capable of consensus rank aggregation was surveyed. The selection criteria were: (1) sound statistical foundations that yield formal p-values or FDR estimates rather than bare rankings, (2) scalability to full transcriptomes (tens of thousands of features), and (3) interpretability for non-programmer users.

1.1 Comparative survey

The table below summarizes the candidate packages across six dimensions: input requirements, key functions, statistical approach, performance and scalability, output metrics, and documentation quality.

Package Input requirements Key functions Statistical approach Performance & scalability Output metrics Documentation
RankProd Expression matrices or paired summary stats (Gene, Score) RankProducts(), RP.advance() Non-parametric (geometric mean of ranks via permutations) Moderate (~15–55 s for massive datasets) Consensus rank, empirical p-values, PFP (FDR), Fold Change Excellent — comprehensive vignettes, well maintained
RobustRankAggreg (RRA) Unweighted pre-ranked lists (gene vectors) aggregateRanks() Probabilistic order statistics (Stuart, exact RRA) Excellent (~20–50 s for massive datasets) Consensus rank, exact p-values, adjusted p-values Good — clear examples, highly cited methodology
RankAggreg Ordered lists of equal length RankAggreg(), BruteAggreg() Heuristic optimization (cross-entropy, genetic algorithms) Poor — computationally heavy; does not scale well beyond 1k genes Consensus ranking only (no formal statistical inference) Acceptable — basic theoretical references
RankAggregator Partial or complete ordinal rankings (matrix) consensusRanking() Distance-based aggregation and combinatorial models (Spearman, Borda) Poor — distance computations unscalable for full transcriptomes Consensus ranking only (no individual p-values) Limited — sparse examples for omics data
TopKLists Multiple ranked lists (focus on top-k elements) Borda(), MC(), CEMC() Probabilistic models (Markov chains, cross-entropy) for partial lists Moderate — optimized for short lists; memory-intensive for >15k genes Consensus ranking, overlap metrics (lacks standard FDR/p-values) Good — clear methodology for partial overlap
metap Raw p-value vectors from independent studies sumlog(), sumz(), minimump() Classical p-value combination (Fisher, Stouffer, Tippett) Excellent — <1 s for millions of p-values Combined p-values Good — standard methodologies, straightforward
staRank Ranked matrices (strict formatting) stabilityRanking() Stability selection based on relative gene positions Poor — slow execution with large-scale omics data Consensus ranking only (no formal statistical significance) Limited — sparse examples, rigid data assumptions

Table 1: Comparative survey of R packages for consensus rank meta-analysis.

1.2 Decision

The survey leads directly to the engines shipped in the suite:

  • MetaRank adopts RankProd (RankProducts() and RP.advance()) and RobustRankAggreg (aggregateRanks() with six aggregation variants: RRA, min, median, mean, geometric mean, and Stuart). Both libraries scale to full transcriptomes and return formal statistical quantities — empirical p-values plus PFP/FDR for RankProd, exact and adjusted p-values for RRA — which is essential for interpretable and publication-ready results.
  • MetaEnrich adopts metap for combining per-dataset enrichment p-values (Fisher, Stouffer, Tippett, Wilkinson), because classical p-value combination is both statistically standard and computationally near-instantaneous.
  • The remaining packages were rejected primarily on grounds of scalability (RankAggreg, RankAggregator, and staRank do not scale to whole-transcriptome inputs) and absence of formal statistical inference (most return consensus rankings only, without p-values or FDR). TopKLists is well-suited to short partial lists but becomes memory-intensive on the large-gene inputs typical of transcriptomic meta-analysis.


2 Robustness to missing features and heterogeneous overlap

Varying overlap between input lists and missing features (dropouts) are ubiquitous challenges in transcriptomic meta-analysis. Robustness to these issues is a core design principle of MetaOmixTools, exposed through two interactive controls:

  • Minimum number of datasets — filters genes by how frequently they appear across independent studies, directly controlling the stringency of overlap.

  • NA management strategy — determines how missing values are handled when computing the consensus. Three strategies are available:

Option Description Recommended use
Impute NA Replaces missing values with the median rank of the gene across available lists. Allows applying an optional extra penalty based on the number of appearances. Exploratory analyses where preserving maximum genes is preferred
Ignore NA Uses only available values, omitting NAs. Also supports optional extra penalization. When preserving all genes while reducing the impact of missing data
Penalize NA Assigns the worst possible rank to missing values (ascending or descending depending on direction). When missing values should be heavily penalized to increase robustness

Table 2: NA management options

An optional Extra Penalization algorithm can be layered on top of Impute or Ignore strategies. This applies an additional proportional penalty to genes with limited dataset support using the following formula:

\[\text{AdjustedRank} = \text{Rank} + \left((\text{TotalLists} - \text{Count}) \times \frac{\text{MaxRank}}{\text{TotalLists}}\right)\]

where Count is the number of lists in which the gene appears. This guarantees that poorly supported genes are pushed down the ranking, preventing rare or spurious features from dominating the top of the consensus.

2.1 Experimental design

Using the four heterogeneous lung-cancer datasets, the consensus ranking was extracted under 10 distinct stringency conditions: two frequency thresholds — Min = 1 (extreme missingness allowed) versus Min = 3 (moderate missingness allowed) — crossed with five NA configurations:

  1. Ignore NA — no penalty
  2. Impute NA — no penalty
  3. Ignore NA — with Extra Penalization
  4. Impute NA — with Extra Penalization
  5. Penalize NA — strict algorithmic penalization

All runs were executed using RankProd basic with ascending ranking direction (lower values = better).

Show code
# ==============================================================================
# BENCHMARKING METAOMIXTOOLS: SENSITIVITY TO MISSING FEATURES
# revision.R
# ==============================================================================

library(dplyr)
library(tidyr)
library(ggplot2)
library(stringr)
library(patchwork)

# Publication-quality theme (Nature/Science style)
theme_publication <- function(base_size = 14) {
  theme_classic(base_size = base_size) +
    theme(
      text = element_text(color = "black"),
      axis.title = element_text(face = "bold", size = rel(1.1)),
      axis.text = element_text(color = "black"),
      axis.text.x = element_text(angle = 45, hjust = 1),
      plot.title = element_text(face = "bold", hjust = 0.5, margin = margin(b = 15)),
      legend.position = "right",
      legend.title = element_text(face = "bold"),
      legend.background = element_rect(fill = "white", color = "black", linewidth = 0.5)
    )
}

# Colour palette for feature frequency
pal_frecuencia <- c(
  "Present in 1 list (Noise)"   = "#e74c3c",
  "Present in 2 lists"          = "#f39c12",
  "Present in 3 lists"          = "#3498db",
  "Present in 4 lists (Robust)" = "#2ecc71"
)

# ── 1. LOAD DATA ──────────────────────────────────────────────────────────────
# Input: 10 TSV files exported from MetaOmixTools (MetaRank results), one per
# condition: 2 thresholds (Min=1, Min=3) × 5 NA strategies.
files <- c(
  "1_ignore_na_no.tsv", "1_ignore_na_yes.tsv", "1_impute_na_no.tsv",
  "1_impute_na_yes.tsv", "1_penalize.tsv",
  "3_ignore_na_no.tsv", "3_ignore_na_yes.tsv", "3_impute_na_no.tsv",
  "3_impute_na_yes.tsv", "3_penalize.tsv"
)

method_levels <- c("Ignore (No Pen)", "Impute (No Pen)", "Ignore (+Pen)",
                   "Impute (+Pen)", "Penalize")

load_data <- function(filename) {
  df <- read.delim(filename, header = TRUE, stringsAsFactors = FALSE)
  df$Threshold <- ifelse(grepl("^1_", filename), "Min=1", "Min=3")
  df$Method <- factor(case_when(
    grepl("ignore_na_no",  filename) ~ "Ignore (No Pen)",
    grepl("ignore_na_yes", filename) ~ "Ignore (+Pen)",
    grepl("impute_na_no",  filename) ~ "Impute (No Pen)",
    grepl("impute_na_yes", filename) ~ "Impute (+Pen)",
    grepl("penalize",      filename) ~ "Penalize",
    TRUE ~ "Unknown"
  ), levels = method_levels)
  df$FileCount <- as.numeric(str_extract(df$FileCount, "^[0-9]+"))
  df
}

all_data <- lapply(files, load_data) %>% bind_rows()

# ── PANEL A: Top-100 composition ──────────────────────────────────────────────
top100_min1 <- all_data %>%
  filter(Threshold == "Min=1") %>%
  group_by(Method) %>% arrange(Rank) %>% slice_head(n = 100) %>% ungroup()

top100_min1$Presence <- factor(top100_min1$FileCount, levels = c(4, 3, 2, 1),
  labels = c("Present in 4 lists (Robust)", "Present in 3 lists",
             "Present in 2 lists", "Present in 1 list (Noise)"))

panel_A <- ggplot(top100_min1, aes(x = Method, fill = Presence)) +
  geom_bar(position = "stack", color = "black") +
  scale_fill_manual(values = pal_frecuencia) +
  labs(title = "A. Feature Overlap in Top 100",
       x = "NA Management Strategy", y = "Number of Genes in Top 100",
       fill = "Feature Profile") +
  theme_publication()

# ── PANEL B: Consensus stability (Min=1 vs Min=3) ─────────────────────────────
top100_min3 <- all_data %>%
  filter(Threshold == "Min=3") %>%
  group_by(Method) %>% arrange(Rank) %>% slice_head(n = 100) %>% ungroup()

overlap_data <- lapply(method_levels, function(m) {
  g1 <- top100_min1 %>% filter(Method == m) %>% pull(GeneID)
  g3 <- top100_min3 %>% filter(Method == m) %>% pull(GeneID)
  data.frame(Method = factor(m, levels = method_levels),
             Retained = (length(intersect(g1, g3)) / 100) * 100)
}) %>% bind_rows()

panel_B <- ggplot(overlap_data, aes(x = Method, y = Retained, fill = Method)) +
  geom_col(color = "black", show.legend = FALSE) +
  scale_fill_brewer(palette = "Purples") +
  labs(title = "B. Consensus Stability (Min=1 vs Min=3)",
       x = "NA Management Strategy", y = "Top 100 Overlap (%)") +
  scale_y_continuous(limits = c(0, 100)) +
  theme_publication()

# ── PANEL C: Individual rank trajectories ─────────────────────────────────────
# Select the top-ranked gene for each frequency class (Ignore No Pen, Min=1)
get_best_gene <- function(data, count_val) {
  data %>%
    filter(Threshold == "Min=1", Method == "Ignore (No Pen)", FileCount == count_val) %>%
    arrange(Rank) %>% head(1) %>% pull(GeneID)
}

g1 <- get_best_gene(all_data, 1)   # Noise gene  (PCAT19)
g2 <- get_best_gene(all_data, 2)   # 2-list gene (ITLN2)
g3 <- get_best_gene(all_data, 3)   # 3-list gene (MCEMP1)
g4 <- get_best_gene(all_data, 4)   # Robust gene (AGER)

traj_data <- all_data %>%
  filter(Threshold == "Min=1", GeneID %in% c(g1, g2, g3, g4))

label_4 <- paste0("Present in 4 lists (Robust) - ", g4)
label_3 <- paste0("Present in 3 lists - ", g3)
label_2 <- paste0("Present in 2 lists - ", g2)
label_1 <- paste0("Present in 1 list (Noise) - ", g1)

traj_data$Gene_Type <- factor(traj_data$FileCount, levels = c(4, 3, 2, 1),
  labels = c(label_4, label_3, label_2, label_1))

pal_panel_c <- setNames(c("#2ecc71", "#3498db", "#f39c12", "#e74c3c"),
                        c(label_4, label_3, label_2, label_1))

panel_C <- ggplot(traj_data, aes(x = Method, y = Rank,
                                  group = Gene_Type, color = Gene_Type)) +
  geom_line(linewidth = 1.5) +
  geom_point(size = 4, shape = 21, fill = "white", stroke = 2) +
  scale_y_reverse() +
  scale_color_manual(values = pal_panel_c) +
  labs(title = "C. Individual Rank Trajectories",
       x = "NA Management Strategy", y = "Consensus Rank (Lower is Better)",
       color = "Feature Profile & Gene ID") +
  theme_publication()

# ── PANEL D: Global rank distribution ─────────────────────────────────────────
dist_data <- all_data %>%
  filter(Threshold == "Min=1", FileCount %in% c(1, 4))

dist_data$Feature_Frequency <- factor(dist_data$FileCount, levels = c(4, 1),
  labels = c("Present in 4 lists (Robust)", "Present in 1 list (Noise)"))

panel_D <- ggplot(dist_data, aes(x = Method, y = Rank, fill = Feature_Frequency)) +
  geom_boxplot(outlier.alpha = 0.2, outlier.size = 0.5) +
  scale_y_reverse() +
  scale_fill_manual(values = c("Present in 4 lists (Robust)" = "#2ecc71",
                               "Present in 1 list (Noise)"   = "#e74c3c")) +
  labs(title = "D. Global Rank Distribution",
       x = "NA Management Strategy", y = "Consensus Rank Distribution",
       fill = "Feature Profile") +
  theme_publication()

# ── Combine panels ────────────────────────────────────────────────────────────
final_grid <- (panel_A | panel_B) / (panel_C | panel_D) +
  plot_annotation(
    title = "Supplementary Figure: Benchmarking NA Management Robustness",
    theme = theme(plot.title = element_text(size = 18, face = "bold", hjust = 0.5))
  )

ggsave("Supplementary_Figure_Full.png", plot = final_grid,
       width = 16, height = 12, dpi = 300)
ggsave("Supplementary_Figure_Full.pdf", plot = final_grid,
       width = 16, height = 12)

2.2 Results

2.2.1 Panel A — Feature overlap in top 100

With high tolerance for missing data (Min = 1) and Ignore NA (no penalty), the top-100 consensus is heavily infiltrated by outlier genes that appear in only a single dataset (shown in red). Applying Extra Penalization or Penalize NA completely removes these artefacts, ensuring the top results consist almost entirely of consistently detected features present in three or four datasets.

2.2.2 Panel B — Consensus stability (Min = 1 vs Min = 3)

Stability was measured as the Jaccard overlap of the top-100 features between the lenient (Min = 1) and strict (Min = 3) thresholds. Naive methods (Ignore and Impute NA without penalty) show lower stability, indicating high sensitivity to missing data. In contrast, Penalize NA strategies achieve near-perfect stability (~100% overlap), demonstrating that mathematical penalization inherently corrects for missing features even when a permissive initial filter is applied.

2.2.3 Panel C — Individual rank trajectories

Four representative genes were tracked across the five NA methods: AGER (present in all 4 datasets — robust), MCEMP1 (3 datasets), ITLN2 (2 datasets) and PCAT19 (1 dataset — noise). Universally detected AGER maintains a stable top-tier rank across all methods. As mathematical strictness increases from left to right along the x-axis, the rank of the noise feature PCAT19 falls dramatically from the top to the bottom of the distribution — an algorithmically driven rank drop that is fully reproducible.

2.2.4 Panel D — Global rank distribution

Without penalization, particularly when imputing, the rank distributions of robust features (4 datasets, green) and high-noise features (1 dataset, red) heavily overlap, making noise statistically indistinguishable from true signal. Applying Penalize NA completely segregates the two distributions, mathematically forcing low-support features to the bottom of the ranking while preserving the statistical priority of multi-dataset features. This panel confirms that the behaviour observed in Panel C is a system-wide correction, not an anecdotal artefact.

Figure 1: Supplementary Figure — Benchmarking of NA Management Strategies and Consensus Robustness. (A) Feature overlap in the top-100 consensus across the five NA strategies. (B) Consensus stability: Jaccard overlap of the top-100 features between Min=1 and Min=3 thresholds. (C) Individual rank trajectories of four representative genes across NA methods. (D) Global rank distribution of robust (4-dataset) versus noise (1-dataset) features.

2.3 Conclusion

Raw meta-analysis is highly sensitive to missing features. However, the mathematical corrections built into the MetaOmixTools parameter space allow researchers to actively control for and neutralise that sensitivity. The recommended practice is to combine a Minimum-datasets threshold with Penalize NA (or Extra Penalization) for robust analyses, and to use Impute / Ignore without penalty only for permissive, exploratory passes.


3 Method concordance (Jaccard similarity)

Users need to know how much their choice of method changes the final result. This study quantifies the pairwise agreement between methods using the Jaccard similarity index applied to the top-ranked feature sets (MetaRank) and the sets of significant terms (MetaEnrich):

\[J(A,B) = \frac{|A \cap B|}{|A \cup B|}\]

All analyses used the same four lung-cancer datasets, with impute NA, Benjamini–Hochberg correction and set.seed(123) throughout for reproducibility.

3.1 Design

To systematically evaluate method concordance, a custom R pipeline was developed to execute all pairwise comparisons across the suite’s algorithms, compute Jaccard similarity indices, and generate the corresponding visualizations. The complete code for this benchmarking pipeline is provided below and can be expanded for review.

Show code
###############################################################################
#  concordance_analysis.R  —  MetaOmixTools method concordance
#
#  Single script: runs all analyses, saves concordance_results.csv,
#  and generates publication-quality TABLE figures (PDF + PNG).
#
#  INPUT  (place in same folder):
#    data1_1.tsv  data2_2.tsv  data3_3.tsv  data4_4.tsv   <- RankProd
#    gene1.txt    gene2.txt    gene3.txt    gene4.txt      <- RRA / MetaEnrich
#
#  OUTPUT:
#    concordance_results.csv       <- full data table
#    concordance_tableA.pdf/png    <- MetaRank symmetric table
#    concordance_tableB.pdf/png    <- MetaEnrich symmetric table
#    concordance_combined.pdf/png  <- both panels side by side
###############################################################################

suppressPackageStartupMessages({
  library(RankProd)
  library(RobustRankAggreg)
  library(clusterProfiler)
  library(org.Hs.eg.db)
  library(metap)
  library(ggplot2)
  library(dplyr)
  library(patchwork)
})

set.seed(123)
OUTPUT_DIR <- "."

# ─────────────────────────────────────────────────────────────────────────────
# HELPERS
# ─────────────────────────────────────────────────────────────────────────────

jaccard <- function(a, b) {
  a <- a[!is.na(a)];  b <- b[!is.na(b)]
  u <- length(union(a, b))
  if (u == 0) return(NA_real_)
  round(length(intersect(a, b)) / u, 3)
}

build_matrix <- function(files) {
  dfs <- lapply(files, function(f)
    read.table(f, header = FALSE, sep = "\t",
               col.names = c("Gene","Score"),
               stringsAsFactors = FALSE, fill = TRUE))
  genes <- unique(unlist(lapply(dfs, `[[`, "Gene")))
  mat   <- sapply(dfs, function(df) {
    v <- setNames(as.numeric(df$Score), df$Gene); v[genes]
  })
  mat <- as.data.frame(mat); rownames(mat) <- genes
  for (j in seq_len(ncol(mat)))
    mat[[j]][is.na(mat[[j]])] <- mean(mat[[j]], na.rm = TRUE)
  mat
}

run_rp <- function(mat) {
  r <- RankProducts(mat, cl = rep(1, ncol(mat)), na.rm = TRUE, logged = TRUE,
                    gene.names = rownames(mat), plot = FALSE,
                    MinNumOfValidPairs = NA)
  d <- data.frame(Gene = rownames(mat), Rank = r$RPrank[,1])
  d[order(d$Rank), ]
}

run_rp_adv <- function(mat, origin) {
  r <- RP.advance(mat, cl = rep(1, ncol(mat)), origin = origin,
                  na.rm = TRUE, logged = TRUE,
                  gene.names = rownames(mat), plot = FALSE)
  d <- data.frame(Gene = rownames(mat), Rank = r$RPrank[,1])
  d[order(d$Rank), ]
}

run_rra <- function(lists, method) {
  r <- as.data.frame(RobustRankAggreg::aggregateRanks(
    glist = lists, method = method, full = TRUE, exact = FALSE))
  colnames(r)[1:2] <- c("Gene","Score")
  r$Rank <- seq_len(nrow(r))
  r[order(r$Rank), ]
}

comb_pval <- function(pv, method) {
  pv <- pv[!is.na(pv) & pv > 0]
  if (length(pv) < 2) return(NA_real_)
  tryCatch(switch(method,
                  Fisher    = metap::sumlog(pv)$p,
                  Stouffer  = metap::sumz(pv)$p,
                  Tippett   = metap::minimump(pv)$p,
                  Wilkinson = metap::wilkinsonp(pv)$p),
           error = function(e) NA_real_)
}

# ─────────────────────────────────────────────────────────────────────────────
# SECTION 1 — MetaRank
# ─────────────────────────────────────────────────────────────────────────────
mat <- build_matrix(c("data1_1.tsv","data2_2.tsv","data3_3.tsv","data4_4.tsv"))
cat(sprintf("  Matrix: %d genes × %d datasets\n", nrow(mat), ncol(mat)))

cat("  Running RP ...\n");         res_rp  <- run_rp(mat)
cat("  Running RP.advance ...\n"); res_rpa <- run_rp_adv(mat, c(1,2,1,2))

rra_lists <- lapply(c("gene1.txt","gene2.txt","gene3.txt","gene4.txt"),
                    function(f) { g <- readLines(f); trimws(g[nzchar(trimws(g))]) })

rra_methods <- c("RRA","min","median","mean","geom.mean","stuart")
cat("  Running 6 RRA variants ...\n")
rra_res <- setNames(lapply(rra_methods, function(m) run_rra(rra_lists, m)),
                    rra_methods)

top_ns <- c(50, 100, 200)
rows   <- list()

# RP vs RP.advance
for (n in top_ns)
  rows[[length(rows)+1]] <- data.frame(
    Module="MetaRank", Group="RankProd",
    Method_A="RP", Method_B="RP.advance", TopN=n,
    Jaccard=jaccard(head(res_rp$Gene,n), head(res_rpa$Gene,n)))

# RP vs every RRA  (cross-library)
for (m in rra_methods)
  for (n in top_ns)
    rows[[length(rows)+1]] <- data.frame(
      Module="MetaRank", Group="RP vs RRA",
      Method_A="RP", Method_B=m, TopN=n,
      Jaccard=jaccard(head(res_rp$Gene,n), head(rra_res[[m]]$Gene,n)))

# ALL pairwise RRA  (all 15 combinations)
cat(sprintf("  Computing all %d RRA-internal pairs ...\n",
            choose(length(rra_methods), 2)))
for (pair in combn(rra_methods, 2, simplify=FALSE))
  for (n in top_ns)
    rows[[length(rows)+1]] <- data.frame(
      Module="MetaRank", Group="RRA internal",
      Method_A=pair[1], Method_B=pair[2], TopN=n,
      Jaccard=jaccard(head(rra_res[[pair[1]]]$Gene,n),
                      head(rra_res[[pair[2]]]$Gene,n)))

mr_df <- do.call(rbind, rows)

# ─────────────────────────────────────────────────────────────────────────────
# SECTION 2 — MetaEnrich
# ─────────────────────────────────────────────────────────────────────────────

ora_full <- lapply(seq_along(rra_lists), function(i) {
  genes <- head(rra_lists[[i]], 500)
  cat(sprintf("    Dataset %d (%d genes) ... ", i, length(genes)))
  res <- tryCatch(
    suppressMessages(enrichGO(
      gene=genes, OrgDb=org.Hs.eg.db, keyType="SYMBOL", ont="BP",
      pvalueCutoff=1, pAdjustMethod="BH", readable=FALSE)),
    error=function(e) NULL)
  if (is.null(res)) { cat("no result\n"); return(NULL) }
  df <- as.data.frame(res@result); df <- df[df$Count>0, ]
  cat(sprintf("%d terms\n", nrow(df))); df
})

all_ids <- unique(unlist(lapply(ora_full, function(d)
  if (!is.null(d)) d$ID else character(0))))
cat(sprintf("  Unique GO:BP terms: %d\n", length(all_ids)))

pval_mat <- sapply(ora_full, function(d) {
  pv <- rep(1, length(all_ids)); names(pv) <- all_ids
  if (!is.null(d)) pv[d$ID] <- d$pvalue; pv
}); rownames(pval_mat) <- all_ids

cat("  Combining p-values ...\n")
comb_methods <- c("Fisher","Stouffer","Tippett","Wilkinson")
comb_pvals   <- setNames(lapply(comb_methods, function(m)
  apply(pval_mat, 1, comb_pval, method=m)), comb_methods)

sig_terms <- lapply(comb_pvals, function(pv) {
  adj <- p.adjust(pv,"BH"); names(adj)[!is.na(adj) & adj<0.05] })
for (m in comb_methods)
  cat(sprintf("    %-12s : %d significant terms\n", m, length(sig_terms[[m]])))

me_rows <- list()
for (pair in combn(comb_methods, 2, simplify=FALSE))
  me_rows[[length(me_rows)+1]] <- data.frame(
    Module="MetaEnrich", Group="p-value combination",
    Method_A=pair[1], Method_B=pair[2], TopN=NA_integer_,
    Jaccard=jaccard(sig_terms[[pair[1]]], sig_terms[[pair[2]]]))

me_df <- do.call(rbind, me_rows)

# ─────────────────────────────────────────────────────────────────────────────
# SAVE CSV
# ─────────────────────────────────────────────────────────────────────────────
all_res <- rbind(mr_df, me_df)
write.csv(all_res, file.path(OUTPUT_DIR,"concordance_results.csv"), row.names=FALSE)

# ─────────────────────────────────────────────────────────────────────────────
# FIGURE HELPERS  —  symmetric coloured table via ggplot
# ─────────────────────────────────────────────────────────────────────────────

BLUE_LOW  <- "#E6F1FB"
BLUE_MID  <- "#378ADD"
BLUE_HIGH <- "#0C447C"

# Map Jaccard value → fill colour
jcol <- function(j) {
  if (is.na(j)) return("grey93")
  scales::col_numeric(c(BLUE_LOW, BLUE_MID, BLUE_HIGH), c(0,1))(j)
}
# Map Jaccard value → legible text colour
tcol <- function(j) if (!is.na(j) && j >= 0.55) "white" else "grey15"

# Build a symmetric matrix from a long data frame, then draw as a ggplot table
draw_table <- function(methods,          
                       long_df,         
                       title, subtitle,
                       note = NULL) {    
  n <- length(methods)
  
  # Fill symmetric matrix
  mat <- matrix(NA_real_, n, n, dimnames = list(methods, methods))
  diag(mat) <- 1
  for (i in seq_len(nrow(long_df))) {
    a <- long_df$A[i]; b <- long_df$B[i]; j <- long_df$Jaccard[i]
    if (a %in% methods && b %in% methods) {
      mat[a, b] <- j; mat[b, a] <- j
    }
  }
  
  # Long format for ggplot
  cells <- expand.grid(row = seq_len(n), col = seq_len(n),
                       stringsAsFactors = FALSE)
  cells$val   <- mapply(function(r,c) mat[r,c], cells$row, cells$col)
  cells$label <- ifelse(is.na(cells$val), "\u2014",
                        ifelse(cells$val==1 & cells$row==cells$col,
                               "1.00", sprintf("%.2f", cells$val)))
  cells$fill  <- sapply(cells$val, jcol)
  cells$tcol  <- sapply(cells$val, tcol)
  
  col_labels <- methods
  row_labels <- methods
  
  p <- ggplot() +
    # tiles
    geom_tile(data = cells, aes(x = col, y = -row),
              fill = cells$fill, color = "white", linewidth = 0.6,
              width = 0.94, height = 0.84) +
    # cell text
    geom_text(data = cells, aes(x = col, y = -row, label = label),
              color = cells$tcol, size = 3.1, family = "sans") +
    # column headers
    annotate("text", x = seq_len(n), y = 0.65,
             label = col_labels, fontface = "bold",
             size = 3.1, color = "grey15", hjust = 0.5) +
    # row labels
    annotate("text", x = 0.22, y = -seq_len(n),
             label = row_labels, size = 3.1,
             color = "grey15", hjust = 1) +
    # axis titles (italic)
    annotate("text", x = mean(seq_len(n)), y = 1.55,
             label = "Method B", size = 3.0, fontface = "italic",
             color = "grey45", hjust = 0.5) +
    annotate("text", x = -0.65, y = -mean(seq_len(n)),
             label = "Method A", size = 3.0, fontface = "italic",
             color = "grey45", angle = 90, hjust = 0.5) +
    scale_x_continuous(limits = c(-1.1, n + 0.7), expand = c(0,0)) +
    scale_y_continuous(limits = c(-n - 0.65, 2.1), expand = c(0,0)) +
    labs(title = title, subtitle = subtitle,
         caption = if (!is.null(note)) note else NULL) +
    theme_void(base_size = 11) +
    theme(
      plot.title    = element_text(face = "bold", size = 12, hjust = 0,
                                   margin = margin(b = 3)),
      plot.subtitle = element_text(size = 9, color = "grey45", hjust = 0,
                                   margin = margin(b = 10)),
      plot.caption  = element_text(size = 7.5, color = "grey50",
                                   hjust = 0, margin = margin(t = 8)),
      plot.margin   = margin(8, 12, 8, 28)
    )
  p
}

# Colour scale legend as a thin ggplot strip
legend_strip <- function() {
  d <- data.frame(x = seq(0, 1, length.out = 200), y = 1)
  ggplot(d, aes(x = x, y = y, fill = x)) +
    geom_tile(height = 0.8, width = 0.006, show.legend = FALSE) +
    scale_fill_gradientn(colors = c(BLUE_LOW, BLUE_MID, BLUE_HIGH),
                         limits = c(0,1)) +
    annotate("text", x = c(0, 0.25, 0.5, 0.75, 1),
             y = 0.05, label = c("0","0.25","0.50","0.75","1.00"),
             size = 2.7, color = "grey30", vjust = 1) +
    annotate("text", x = 0.5, y = 1.9,
             label = "Jaccard similarity",
             size = 2.9, color = "grey30", hjust = 0.5) +
    scale_x_continuous(limits = c(-0.05, 1.05), expand = c(0,0)) +
    scale_y_continuous(limits = c(-0.3, 2.4), expand = c(0,0)) +
    theme_void() +
    theme(plot.margin = margin(2, 40, 2, 40))
}

# ─────────────────────────────────────────────────────────────────────────────
# TABLE A — MetaRank  (top-100)
# ─────────────────────────────────────────────────────────────────────────────
mr100 <- all_res %>%
  filter(Module == "MetaRank", TopN == 100) %>%
  select(A = Method_A, B = Method_B, Jaccard)

# For the RP-vs-RRA cross-library rows the Method_B was stored as e.g. "RRA"
# (the run_rra method name), so it already matches the rra_methods labels.
# RP.advance has Method_B = "RP.advance" — keep as is.

all_mr_methods <- c("RP","RP.advance","RRA","min","median","mean","geom.mean","stuart")
short <- c("RP"="RP","RP.advance"="RP.adv","RRA"="RRA","min"="min",
           "median"="median","mean"="mean","geom.mean"="geo.m","stuart"="Stuart")
mr100$A <- ifelse(mr100$A %in% names(short), short[mr100$A], mr100$A)
mr100$B <- ifelse(mr100$B %in% names(short), short[mr100$B], mr100$B)
display_methods <- unname(short[all_mr_methods])

fig_A <- draw_table(
  methods  = display_methods,
  long_df  = mr100,
  title    = "A   MetaRank \u2014 pairwise ranking agreement",
  subtitle = paste0("Jaccard similarity, top-100 genes  |  ",
                    "Grey cells = pair not applicable or self-comparison"),
  note     = paste("RP.advance uses origin vector (1,2,1,2) on four lung cancer",
                   "studies (GSE10082, GSE19188, GSE63459, GSE75037).")
)

# ─────────────────────────────────────────────────────────────────────────────
# TABLE B — MetaEnrich
# ─────────────────────────────────────────────────────────────────────────────
me_long <- me_df %>% select(A = Method_A, B = Method_B, Jaccard)

fig_B <- draw_table(
  methods  = comb_methods,
  long_df  = me_long,
  title    = "B   MetaEnrich \u2014 term-set agreement",
  subtitle = "Jaccard similarity on significant GO:BP terms (BH < 0.05)",
  note     = "ORA input: top-500 genes per ranked list; org.Hs.eg.db 3.23.1."
)

# ─────────────────────────────────────────────────────────────────────────────
# COMBINE
# ─────────────────────────────────────────────────────────────────────────────
leg <- legend_strip()

dataset_caption <- paste(
  "Datasets: four lung cancer studies (GSE10082, GSE19188, GSE63459, GSE75037).",
  "MetaRank: RankProd input 22k\u201355k genes/study; RRA input 13k\u201321k genes/list.",
  "MetaEnrich: top-500 genes/list; ORA against GO:BP (org.Hs.eg.db 3.23.1).",
  "All analyses: impute NA, BH correction, set.seed(123).",
  sep = "  "
)

combined <- (fig_A | fig_B) /
  (leg + plot_spacer() + plot_layout(widths = c(1, 2.5))) +
  plot_layout(heights = c(9, 1)) +
  plot_annotation(
    caption = dataset_caption,
    theme   = theme(
      plot.caption = element_text(size = 7.5, color = "grey45",
                                  lineheight = 1.35, hjust = 0,
                                  margin = margin(t = 6)),
      plot.margin  = margin(6, 10, 6, 6)
    )
  )

# ─────────────────────────────────────────────────────────────────────────────
# SAVE
# ─────────────────────────────────────────────────────────────────────────────
save_fig <- function(plot, base, w, h) {
  ggsave(file.path(OUTPUT_DIR, paste0(base,".pdf")),
         plot, width=w, height=h, device=cairo_pdf)
  ggsave(file.path(OUTPUT_DIR, paste0(base,".png")),
         plot, width=w, height=h, dpi=300)
  cat(sprintf("  %s.pdf / .png  (%g x %g in)\n", base, w, h))
}

save_fig(fig_A,    "concordance_tableA",    8.0, 5.8)
save_fig(fig_B,    "concordance_tableB",    4.8, 3.8)
save_fig(combined, "concordance_combined", 13.0, 7.5)

3.1.1 MetaRank — three comparison families

  • RP vs RP.advance — the two *RankProd entry points (origin vector (1,2,1,2) for RP.advance).
  • RP vs every RRA variant — cross-library agreement between RankProd and RobustRankAggreg.
  • All pairwise RRA variants — the 15 internal combinations among RRA, min, median, mean, geometric mean and Stuart.

Agreement was computed at top-N = 50, 100 and 200.

3.1.2 MetaEnrich — p-value combination agreement

All 6 pairwise comparisons among Fisher, Stouffer, Tippett and Wilkinson, on the set of significant GO:BP terms (BH-adjusted combined p < 0.05). ORA was run on the top-500 genes per ranked list against GO:BP using org.Hs.eg.db v3.23.1.

3.2 Results

The complete set of pairwise Jaccard similarity scores generated by the benchmarking pipeline is provided in the interactive table below. This dataset, parsed directly from a raw .csv file, includes all cross-method comparisons for MetaRank at multiple rank thresholds (Top-50, Top-100, and Top-200), as well as the term-set agreement for MetaEnrich.

Module Group Method_A Method_B TopN Jaccard
MetaRank RankProd RP RP.advance 50 1.00
MetaRank RankProd RP RP.advance 100 1.00
MetaRank RankProd RP RP.advance 200 1.00
MetaRank RP vs RRA RP RRA 50 587.00
MetaRank RP vs RRA RP RRA 100 709.00
MetaRank RP vs RRA RP RRA 200 646.00
MetaRank RP vs RRA RP min 50 429.00
MetaRank RP vs RRA RP min 100 418.00
MetaRank RP vs RRA RP min 200 455.00
MetaRank RP vs RRA RP median 50 449.00
MetaRank RP vs RRA RP median 100 481.00
MetaRank RP vs RRA RP median 200 476.00
MetaRank RP vs RRA RP mean 50 724.00
MetaRank RP vs RRA RP mean 100 754.00
MetaRank RP vs RRA RP mean 200 754.00
MetaRank RP vs RRA RP geom.mean 50 587.00
MetaRank RP vs RRA RP geom.mean 100 481.00
MetaRank RP vs RRA RP geom.mean 200 504.00
MetaRank RP vs RRA RP stuart 50 923.00
MetaRank RP vs RRA RP stuart 100 905.00
MetaRank RP vs RRA RP stuart 200 869.00
MetaRank RRA internal RRA min 50 299.00
MetaRank RRA internal RRA min 100 351.00
MetaRank RRA internal RRA min 200 384.00
MetaRank RRA internal RRA median 50 333.00
MetaRank RRA internal RRA median 100 449.00
MetaRank RRA internal RRA median 200 493.00
MetaRank RRA internal RRA mean 50 754.00
MetaRank RRA internal RRA mean 100 0.77
MetaRank RRA internal RRA mean 200 702.00
MetaRank RRA internal RRA geom.mean 50 449.00
MetaRank RRA internal RRA geom.mean 100 481.00
MetaRank RRA internal RRA geom.mean 200 544.00
MetaRank RRA internal RRA stuart 50 613.00
MetaRank RRA internal RRA stuart 100 0.77
MetaRank RRA internal RRA stuart 200 702.00
MetaRank RRA internal min median 50 351.00
MetaRank RRA internal min median 100 418.00
MetaRank RRA internal min median 200 476.00
MetaRank RRA internal min mean 50 316.00
MetaRank RRA internal min mean 100 342.00
MetaRank RRA internal min mean 200 347.00
MetaRank RRA internal min geom.mean 50 471.00
MetaRank RRA internal min geom.mean 100 481.00
MetaRank RRA internal min geom.mean 200 562.00
MetaRank RRA internal min stuart 50 408.00
MetaRank RRA internal min stuart 100 399.00
MetaRank RRA internal min stuart 200 444.00
MetaRank RRA internal median mean 50 389.00
MetaRank RRA internal median mean 100 399.00
MetaRank RRA internal median mean 200 404.00
MetaRank RRA internal median geom.mean 50 724.00
MetaRank RRA internal median geom.mean 100 724.00
MetaRank RRA internal median geom.mean 200 818.00
MetaRank RRA internal median stuart 50 429.00
MetaRank RRA internal median stuart 100 471.00
MetaRank RRA internal median stuart 200 487.00
MetaRank RRA internal mean geom.mean 50 515.00
MetaRank RRA internal mean geom.mean 100 0.46
MetaRank RRA internal mean geom.mean 200 434.00
MetaRank RRA internal mean stuart 50 786.00
MetaRank RRA internal mean stuart 100 818.00
MetaRank RRA internal mean stuart 200 802.00
MetaRank RRA internal geom.mean stuart 50 587.00
MetaRank RRA internal geom.mean stuart 100 504.00
MetaRank RRA internal geom.mean stuart 200 533.00
MetaEnrich p-value combination Fisher Stouffer NA 828.00
MetaEnrich p-value combination Fisher Tippett NA 562.00
MetaEnrich p-value combination Fisher Wilkinson NA 562.00
MetaEnrich p-value combination Stouffer Tippett NA 473.00
MetaEnrich p-value combination Stouffer Wilkinson NA 473.00
MetaEnrich p-value combination Tippett Wilkinson NA 1.00

Table 3: All results of pairwise Jaccard similarity.

3.2.1 MetaRank — top-100 agreement matrix

  • RP and RP.advance are fully concordant at the top-100 level (J = 1.00): on this corpus the origin vector (1,2,1,2) does not reshuffle the leading consensus, confirming robustness of the RP algorithm to the batch-assignment parameter.
  • RP ↔︎ Stuart (J = 0.91) is the strongest cross-library agreement, followed by RP ↔︎ mean (J = 0.75) and RRA ↔︎ mean (J = 0.77). This indicates that RankProd’s permutation-based consensus converges most closely with the Stuart order statistic and arithmetic-mean aggregation.
  • min is the most idiosyncratic RRA variant (J ≤ 0.42 vs RP; J = 0.35 vs RRA), consistent with its extreme sensitivity to the single best rank across lists.
  • median ↔︎ geo.m (J = 0.72) and mean ↔︎ Stuart (J = 0.82) cluster together, reflecting shared central-tendency behaviour. These natural groupings allow users to understand which variants are methodologically closest before selecting one.
  • Trends are consistent at top-50 and top-200: for example, RP ↔︎ Stuart remains high across thresholds (0.92 → 0.91 → 0.87).

Figure 2: MetaRank pairwise ranking agreement (Jaccard, top-100 genes). Em-dash cells indicate pairs not applicable (RP.advance is only compared against RP) or self-comparisons.

3.2.2 MetaEnrich — term-set agreement matrix

  • Tippett and Wilkinson are fully concordant (J = 1.00) on this corpus — both are minimum-based combiners and select the identical set of significant GO:BP terms here.
  • Fisher ↔︎ Stouffer (J = 0.83) show strong agreement, as expected from two evidence-pooling combiners that weight all p-values collectively.
  • The two families (Fisher/Stouffer vs Tippett/Wilkinson) agree only moderately (~0.47–0.56), reflecting their fundamentally different sensitivities: Fisher and Stouffer detect consistent moderate signals across all datasets, while Tippett and Wilkinson are driven by the strongest signal in even a single dataset. The choice of combiner is therefore biologically meaningful and should be explicitly reported in any publication.

Figure 3: MetaEnrich term-set agreement (Jaccard on significant GO:BP terms, BH < 0.05). ORA input: top-500 genes per ranked list; org.Hs.eg.db v3.23.1.

3.3 Conclusion

Concordance is high within statistically related method families and the cross-library RP ↔︎ Stuart/mean agreement confirms the two MetaRank engines converge on a shared biological signal rather than producing arbitrary orderings. Where methods diverge (RRA min; Tippett/Wilkinson vs Fisher/Stouffer), the divergence is interpretable and reflects deliberate mathematical differences in stringency. The suite’s reproducibility exports make the chosen method fully auditable.


4 Execution time and stress testing

MetaOmixTools processes pre-computed summary statistics and feature lists, not raw sequencing data (BAM/FASTQ). By design this minimises memory requirements: the upload limit is set to 30 MB, which is more than sufficient for gene lists of any practically relevant size. Validated reference capacities include 12 lists of ~20,000 genes and 50 lists of 100–500 genes processed simultaneously without issue.

All times reported are robust means of triplicate executions, measured by recording wall-clock time via proc.time() for each module and configuration. Benchmarking was performed manually by sequentially launching each of the 24 MetaRank configurations and 8 MetaEnrich configurations from the suite’s web interface, changing parameters between runs, and recording the reported analysis time.

4.1 Standard benchmarking — typical conditions

Inputs. Four lung-cancer studies with module-appropriate formats:

  • MetaRank / RankProd: tabulated files of 54,673 / 22,278 / 24,525 / 48,803 genes.
  • MetaRank / RRA: ranked gene lists of 19,407 / 21,753 / 17,509 / 13,099 genes.
  • MetaEnrich: ranked gene lists of 19,407 / 21,753 / 17,509 / 13,099 genes.

Baseline parameters. ORA fixed at top-100 genes against GO:BP (Homo sapiens, Gene Symbols). RankProd runs used ascending direction, imputed NA and extra penalisation; RP.advance used origin vector (1,2,1,2). RRA runs used minimum-dataset thresholds of 1 and 4 with penalisation of missing values.

4.1.1 MetaRank — standard runtimes

Configuration Meta-analysis core (s) Total without ORA (s)
RankProd — RP 1.51 19.71
RankProd — RP.advance 1.84 20.15
RRA — RRA variant 2.64 18.65
RRA — Arithmetic Mean — 16.96 (fastest)
RRA — Geometric Mean — 17.41
RRA — Stuart — 19.68 (upper bound)

Table 4: MetaRank standard runtimes — means of triplicates. RRA total times include rendering; core is the meta-analysis step only.

Adding ORA on the top-100 genes introduced a stable overhead of approximately 11.5–12.0 s, giving total runtimes with enrichment of 28 to 32 s. Notably, running ORA on the full low-threshold gene lists (up to ~19,000 genes — approximately 190× larger) required only 9.3–10.1 s, marginally less time than the top-100 case. This is because the computational bottleneck is the annotation-database lookup and term-level statistical testing, not gene-list size once a threshold is crossed. The system therefore scales sub-linearly with input size.

A compensatory dynamic was also observed: RankProd’s permutation core is faster than RRA’s (1.51 s vs 2.64 s), but its heavier result-table construction makes its total wall-clock time slightly longer, so RRA wins on total time. The difference between RP and RP.advance is minimal (0.33 s in the core step under standard conditions).

4.1.2 MetaEnrich — standard runtimes

Minimum-datasets threshold Terms passing filter ORA (s) Meta-analysis (s) Render (s) Total (s)
1 1,416 37.18 5.39 0.06 42.63
4 356 36.67 2.80 0.06 39.53

Table 5: MetaEnrich standard runtimes — means of triplicates across all four combiners (Fisher, Stouffer, Tippett, Wilkinson).

The statistical combiner (Fisher, Stouffer, Tippett or Wilkinson) had no meaningful impact on runtime (<1 s spread across methods): method selection is entirely performance-neutral. Quadrupling the number of processed terms (356 → 1,416) raised total runtime by only ~3.1 s, confirming high efficiency in the meta-analysis engine.

4.2 Extreme stress testing — maximum capacity

To characterise the absolute upper computational bound, a biologically implausible worst-case scenario was constructed using script_datos.R: the complete Ensembl BioMart gene nomenclature (~80,000 identifiers) was used to synthetically generate 10 massive, maximally heterogeneous datasets with asymmetric overlaps and maximum missing values. Gene sizes per list were: 40,000 / 25,000 / 15,000 / 5,000 / 30,000 / 39,000 / 1,000 / 7,500 / 10,000 / 18,000. Scores were drawn from a rbeta(shape1 = 1, shape2 = 5) distribution with set.seed(123).

4.2.1 MetaRank — extreme conditions

Configuration Core meta-analysis (s) Total without ORA (s) With universal ORA (s)
RankProd — RP 8.88 88.50 ~102
RankProd — RP.advance 9.78 85.41 98.55 (worst case)
RRA — RRA variant 6.09 73.89 86.35 (fastest extreme)
RRA — Stuart 8.32 77.75 ~91

Table 6: MetaRank runtimes under the 10-dataset, ~80,000-gene worst case.

The ORA penalty when running on all passing features against GO:BP was a stable ~13 s across all methods (RP: 13.51 s; RP.advance: 13.32 s; RRA: 13.06 s; Stuart: 12.88 s). The gap between basic and advanced variants within each family remained <4 s despite the extreme data volume, confirming that the algorithm scales gracefully.

4.2.2 MetaEnrich — extreme conditions

Using the Wilkinson combiner with the most permissive threshold across all 10 massive datasets, 2,886 terms passed the filter:

Step Time (s)
ORA computation 113.17
Meta-analysis 18.81
Render 2.12
Total 134.10

Table 7: MetaEnrich worst-case runtime — Wilkinson, Min=1, 10 datasets of ~80,000 genes.

4.3 Summary table

Module Condition Input volume Approximate runtime
MetaRank Standard 4 datasets (~20,000 genes each) 17 to 32 seconds
MetaRank Extreme 10 datasets (~80,000 genes each) 73 to 99 seconds
MetaEnrich Standard 4 datasets (significant genes only) 39 to 43 seconds
MetaEnrich Extreme 10 datasets (highly permissive threshold) ~134 seconds

Table 8: Performance summary across standard and extreme conditions.

4.4 Browser, server and concurrency

  • Client memory. All interactive results tables are paginated to 10 rows per view; downloadable HTML reports are capped at the top 100 features to prevent DOM overflow and browser freezing on standard hardware.
  • Hosting. The public instance runs on the Bioinformatics Department infrastructure at CIPF (bioinfo.cipf.es/metaomixtools), a Linux cluster with dedicated CPU and RAM allocation for web bioinformatics services, hosted within a Docker container for version stability.
  • Concurrency. Shiny Server spawns an independent R process per user session. Because inputs are lightweight pre-computed summary statistics, per-session RAM is very low. Traffic spikes are handled by the native queue (a brief delay), not by memory exhaustion or application failure.

4.5 Conclusion

Even under a biologically impossible worst-case scenario — 10 full transcriptomes with ~80,000 genes, maximal noise and no biological pre-filtering — the platform resolves entirely within 2.5 minutes (134 s maximum). Under typical real-world conditions, complete analyses finish in 17–43 seconds. This empirically eliminates any concern about server timeouts or uncontrolled bottlenecks.