File size: 21,611 Bytes
bfe6079
 
 
 
 
 
 
 
 
 
 
 
 
312cab8
bfe6079
 
 
49f5c84
 
 
 
 
 
 
 
 
 
bfe6079
 
 
 
49f5c84
bfe6079
 
49f5c84
ec3eabb
 
bfe6079
 
 
 
 
 
 
 
49f5c84
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bfe6079
c3b49d6
bfe6079
c3b49d6
bfe6079
 
 
 
 
 
 
c3b49d6
 
bfe6079
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c3b49d6
 
bfe6079
 
 
 
 
 
 
 
 
 
 
 
 
c3b49d6
49f5c84
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c3b49d6
bfe6079
 
 
 
 
 
 
 
 
 
 
c3b49d6
bfe6079
c3b49d6
bfe6079
 
 
 
49f5c84
 
c3b49d6
bfe6079
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
49f5c84
 
 
 
bfe6079
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
49f5c84
 
 
 
 
 
 
 
 
 
bfe6079
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
49f5c84
 
 
 
 
bfe6079
 
 
 
49f5c84
 
bfe6079
 
 
ed8955e
 
 
 
 
c3b49d6
bfe6079
c3b49d6
49f5c84
 
 
 
bfe6079
 
 
49f5c84
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bfe6079
49f5c84
bfe6079
 
 
49f5c84
 
 
 
bfe6079
 
 
 
 
 
 
 
 
 
 
 
 
c3b49d6
bfe6079
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c3b49d6
bfe6079
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
49f5c84
 
 
bfe6079
 
 
 
 
 
 
 
 
 
 
 
 
49f5c84
 
 
 
 
bfe6079
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
"""
Transcription factor and pathway activity scoring workflow helpers.

Two families of functions live here:

1. Manifest-aware parameter helpers
   ----------------------------------
   get_scoring_params   Extract organism, contrast, and applicable workflows
                        from a dataset manifest.

2. Generic sample-level scoring
   ------------------------------
   score_bulk_samples_with_decoupler
                        Run decoupleR activity estimation (ULM/MLM/zscore) on a
                        pre-normalised samples Γ— genes expression DataFrame.
                        Returns a samples Γ— activities DataFrame and diagnostic
                        metadata.  Does not hard-code any dataset.

3. Input-scale + reporting helpers
   ---------------------------------
   detect_expression_scale
                        Heuristic: is a matrix on a log scale (what ULM assumes)
                        or on a linear scale (raw TPM/CPM, which distorts ULM)?
   plot_activity_landscape
                        Standard cohort-wide landscape figure (per-sample
                        heatmap + meanΒ±SD bar) written to a declared artifact
                        path so the UI's output sweep can embed it.
"""

from __future__ import annotations

from pathlib import Path
from typing import Any

import numpy as np
import pandas as pd

# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------

SUPPORTED_RESOURCES: frozenset[str] = frozenset({"progeny", "collectri", "hallmark"})

SUPPORTED_METHODS: frozenset[str] = frozenset({"ulm", "mlm", "zscore"})

SUPPORTED_INPUT_SCALES: frozenset[str] = frozenset({"auto", "log", "linear"})

# Above this maximum value a matrix is treated as *linear* expression.
# log2-TPM / log-CPM / log microarray intensity all top out around 16-20, so a
# max above 50 means the values were never log-transformed.
LOG_SCALE_MAX_THRESHOLD: float = 50.0

# PROGENy footprint size, pinned explicitly.  decoupler's own default for
# ``dc.op.progeny(top=...)`` has changed across releases, so leaving it implicit
# makes scores irreproducible across upgrades.  500 is the footprint the PROGENy
# publication and decoupler tutorials use for bulk data.
PROGENY_TOP: int = 500

# Maximum number of activity columns drawn in the landscape heatmap.  CollecTRI
# scores ~1 000 TFs, which is unreadable; the most variable ones are kept.
LANDSCAPE_MAX_FEATURES: int = 50

_RESOURCE_DESCRIPTIONS: dict[str, str] = {
    "progeny": "PROGENy β€” 14 cancer signalling pathway signatures",
    "collectri": "CollecTRI β€” TF–target regulons (~1 000 TFs)",
    "hallmark": "MSigDB Hallmark β€” 50 curated gene sets",
}


# ---------------------------------------------------------------------------
# Manifest helpers
# ---------------------------------------------------------------------------


def get_scoring_params(manifest: dict | object) -> dict[str, Any]:
    """
    Extract activity scoring parameters from a dataset manifest.

    Accepts both a DatasetManifest dataclass instance and a raw dict.

    Returns
    -------
    dict with keys:
        organism  (str)  β€” "human" or "mouse".
        contrast  (dict) β€” design_factor, test_group, control_group, method.
        workflows (list) β€” which workflow modules apply to this dataset.
    """
    if hasattr(manifest, "organism"):
        return {
            "organism": manifest.organism,
            "contrast": (manifest.default_contrasts or [{}])[0]
            if hasattr(manifest, "default_contrasts")
            else {},
            "workflows": getattr(manifest, "valid_workflows", []),
        }
    return {
        "organism": manifest.get("organism", "human"),
        "contrast": manifest.get("contrast", {}),
        "workflows": manifest.get("workflows", []),
    }


# ---------------------------------------------------------------------------
# Internal helper
# ---------------------------------------------------------------------------


def detect_expression_scale(expression_df: pd.DataFrame) -> dict[str, Any]:
    """
    Heuristically decide whether an expression matrix is log-scale or linear.

    ULM/MLM (and PROGENy's weights) assume roughly symmetric, log-scale input.
    Linear TPM/CPM is dominated by a handful of very highly expressed genes, so
    feeding it to ULM distorts every activity score.  This detector is the guard
    on that: it is deliberately conservative and looks only at the value range.

    Returns
    -------
    dict with keys:
        scale        (str)   β€” "log", "linear", or "unknown" (all-NaN input).
        max_value    (float | None)
        min_value    (float | None)
        has_negative (bool)
        skew         (float | None) β€” Fisher-Pearson skewness, diagnostic only.
    """
    values = expression_df.to_numpy(dtype=float, copy=False)
    finite = values[np.isfinite(values)]

    if finite.size == 0:
        return {
            "scale": "unknown",
            "max_value": None,
            "min_value": None,
            "has_negative": False,
            "skew": None,
        }

    max_value = float(np.max(finite))
    min_value = float(np.min(finite))
    has_negative = min_value < 0

    std = float(np.std(finite))
    skew = float(np.mean(((finite - np.mean(finite)) / std) ** 3)) if std > 0 else 0.0

    # Negative values mean the matrix is already log-transformed and/or centred
    # (log-ratio, VST, z-scored) β€” linear abundances are non-negative.
    if has_negative:
        scale = "log"
    elif max_value > LOG_SCALE_MAX_THRESHOLD:
        scale = "linear"
    else:
        scale = "log"

    return {
        "scale": scale,
        "max_value": max_value,
        "min_value": min_value,
        "has_negative": has_negative,
        "skew": skew,
    }


def plot_activity_landscape(
    activity_df: pd.DataFrame,
    out_path: str | Path,
    title: str = "Activity landscape",
    max_features: int = LANDSCAPE_MAX_FEATURES,
) -> dict[str, Any]:
    """
    Render the standard cohort-wide activity landscape figure.

    Two stacked panels:
      1. samples Γ— activities heatmap (per-sample scores, the "landscape"),
      2. cohort mean Β± SD bar chart per activity.

    Written by the tool itself so the figure always lands in ``OUTPUT_DIR`` and
    is returned as a declared artifact β€” agent-authored matplotlib lands wherever
    the agent happens to choose and is never picked up by the UI's output sweep.

    When there are more than ``max_features`` activities, the most variable
    ``max_features`` are plotted (reported in the return dict).

    Returns
    -------
    dict with keys: path, n_samples, n_features_plotted, n_features_total,
    features_plotted, truncated.
    """
    import matplotlib

    matplotlib.use("Agg")
    import matplotlib.pyplot as plt

    out_path = Path(out_path)
    out_path.parent.mkdir(parents=True, exist_ok=True)

    n_total = int(activity_df.shape[1])
    plot_df = activity_df
    truncated = n_total > max_features
    if truncated:
        keep = activity_df.std(axis=0).sort_values(ascending=False).head(max_features).index
        plot_df = activity_df.loc[:, keep]

    # Stable, readable ordering: highest mean activity on the left.
    order = plot_df.mean(axis=0).sort_values(ascending=False).index
    plot_df = plot_df.loc[:, order]

    n_features = int(plot_df.shape[1])
    n_samples = int(plot_df.shape[0])

    width = max(6.0, min(0.32 * n_features + 3.0, 24.0))
    height = max(6.0, min(0.06 * n_samples + 5.0, 20.0))

    fig, (ax_heat, ax_bar) = plt.subplots(
        2, 1, figsize=(width, height), height_ratios=[3, 1], constrained_layout=True
    )

    vmax = float(np.nanmax(np.abs(plot_df.to_numpy(dtype=float)))) if n_features else 1.0
    vmax = vmax if vmax > 0 else 1.0
    im = ax_heat.imshow(
        plot_df.to_numpy(dtype=float),
        aspect="auto",
        cmap="RdBu_r",
        vmin=-vmax,
        vmax=vmax,
        interpolation="nearest",
    )
    ax_heat.set_xticks(range(n_features))
    ax_heat.set_xticklabels(plot_df.columns, rotation=90, fontsize=7)
    ax_heat.set_ylabel(f"Samples (n={n_samples})")
    # Sample labels are unreadable past a few dozen rows; the heatmap is a
    # cohort-level view, and per-sample values live in the CSV.
    if n_samples <= 40:
        ax_heat.set_yticks(range(n_samples))
        ax_heat.set_yticklabels(plot_df.index, fontsize=6)
    else:
        ax_heat.set_yticks([])
    ax_heat.set_title(title)
    fig.colorbar(im, ax=ax_heat, label="Activity score", fraction=0.02, pad=0.01)

    means = plot_df.mean(axis=0)
    sds = plot_df.std(axis=0)
    ax_bar.bar(range(n_features), means.to_numpy(), yerr=sds.to_numpy(), capsize=2, color="#4C72B0")
    ax_bar.axhline(0, color="black", linewidth=0.8)
    ax_bar.set_xticks(range(n_features))
    ax_bar.set_xticklabels(plot_df.columns, rotation=90, fontsize=7)
    ax_bar.set_ylabel("Mean Β± SD")

    fig.savefig(out_path, dpi=200, bbox_inches="tight")
    plt.close(fig)

    return {
        "path": str(out_path.resolve()),
        "n_samples": n_samples,
        "n_features_plotted": n_features,
        "n_features_total": n_total,
        "features_plotted": list(plot_df.columns),
        "truncated": truncated,
    }


def _find_network_target_col(net: pd.DataFrame) -> str | None:
    """Return the target/gene column name in a decoupler network, or None."""
    for col in ("target", "gene", "Gene", "to"):
        if col in net.columns:
            return col
    return None


# ---------------------------------------------------------------------------
# Generic sample-level scoring
# ---------------------------------------------------------------------------


def score_bulk_samples_with_decoupler(
    expression_df: pd.DataFrame,
    resource: str = "progeny",
    organism: str = "human",
    method: str = "ulm",
    min_n: int = 5,
    input_scale: str = "auto",
    progeny_top: int = PROGENY_TOP,
    _network: pd.DataFrame | None = None,
) -> dict[str, Any]:
    """
    Score bulk expression samples using decoupleR activity estimation.

    Computes per-sample activity scores for pathways (PROGENy), TF regulons
    (CollecTRI), or hallmark gene sets by fitting a linear model (ULM/MLM)
    or weighted sum (WSUM) across the genes in each regulon/pathway.

    This function is generic β€” it does not assume any specific dataset.
    Pass any pre-normalised samples Γ— genes expression matrix.

    Input requirements
    ------------------
    expression_df:
        Samples as rows (index = sample IDs), genes as columns (HGNC symbols).
        Values must be pre-normalised (log-CPM, log-intensity, log2-TPM, etc.).
        Do NOT pass raw integer counts β€” activity estimation assumes continuous,
        roughly symmetric expression values.  The scale is checked rather than
        assumed: with ``input_scale="auto"`` (the default) a matrix that looks
        linear (e.g. raw TPM) is log2(x+1)-transformed before scoring, and the
        transform actually applied is reported in the return dict.

    Output
    ------
    activity_df:
        Same row index as expression_df.  Columns are activity names
        (pathway names, TF names, or hallmark names).

    Parameters
    ----------
    resource:
        "progeny"   β€” PROGENy pathway signatures (14 pathways).
        "collectri" β€” CollecTRI TF regulons (~1 000 TFs, human/mouse).
        "hallmark"  β€” MSigDB Hallmark gene sets (50 sets).
    organism:
        "human" or "mouse".
    method:
        "ulm"  β€” Univariate Linear Model (recommended; fastest).
        "mlm"  β€” Multivariate Linear Model (accounts for co-linearity).
        "zscore" β€” Z-score (simpler, no distributional assumptions).
    min_n:
        Minimum expected samples. Emits a warning when fewer are present.
    input_scale:
        "auto"   β€” detect the scale (see detect_expression_scale) and log2(x+1)
                   transform the matrix when it looks linear, with a warning.
        "log"    β€” trust the caller: never transform.  A matrix that still looks
                   linear raises a warning (it is very likely a mistake).
        "linear" β€” always log2(x+1) transform.
    progeny_top:
        PROGENy footprint size (genes per pathway) passed to dc.op.progeny.
        Pinned by default (PROGENY_TOP) so scores are reproducible across
        decoupler upgrades; ignored for other resources.
    _network:
        For testing only.  Supply a pre-loaded network DataFrame to bypass
        the decoupler network download (dc.op.*).  Not exposed in MCP tools.

    Returns
    -------
    dict with keys:
        activity_df     (pd.DataFrame) β€” samples Γ— activities.
        pvalue_df       (pd.DataFrame) β€” samples Γ— activities (p-values).
        n_samples       (int)
        n_activities    (int)   β€” number of scored activities (sources).
        n_network_genes (int)   β€” total target genes in the network.
        n_matched_genes (int)   β€” network genes present in expression_df.
        coverage_pct    (float) β€” % of network genes matched; None if unknown.
        resource        (str)
        organism        (str)
        method          (str)
        input_scale_requested (str)   β€” the input_scale argument.
        input_scale_detected  (str)   β€” "log", "linear", or "unknown".
        applied_transform     (str)   β€” "none" or "log2(x+1)".
        expression_max        (float | None) β€” max input value, pre-transform.
        network_top     (int | None)  β€” pinned PROGENy footprint size, else None.
        warnings        (list[str])

    Raises
    ------
    ValueError  if resource, method, or input_scale is not in the supported set,
                or if a log transform is requested on a matrix with negatives.
    """
    import decoupler as dc

    # A caller-supplied network (``_network``) is a custom signature β€” e.g. a
    # Loveless-derived cell-state signature scored against a bulk cohort
    # (ADR-0006 Role 2). In that case ``resource`` is just a free-form label for
    # outputs/diagnostics, so the built-in-resource gate does not apply.
    if _network is None and resource not in SUPPORTED_RESOURCES:
        raise ValueError(f"resource must be one of {sorted(SUPPORTED_RESOURCES)}, got '{resource}'")
    if method not in SUPPORTED_METHODS:
        raise ValueError(f"method must be one of {sorted(SUPPORTED_METHODS)}, got '{method}'")
    if input_scale not in SUPPORTED_INPUT_SCALES:
        raise ValueError(
            f"input_scale must be one of {sorted(SUPPORTED_INPUT_SCALES)}, got '{input_scale}'"
        )

    run_warnings: list[str] = []

    # ── Input scale: ULM assumes log-scale, symmetric values ─────────────
    # Linear TPM/CPM is dominated by a few very highly expressed genes, which
    # silently distorts every activity score, so the scale is checked (and by
    # default corrected) rather than assumed from the docstring.
    scale_info = detect_expression_scale(expression_df)
    detected = scale_info["scale"]
    applied_transform = "none"

    if input_scale == "linear" or (input_scale == "auto" and detected == "linear"):
        if scale_info["has_negative"]:
            raise ValueError(
                "Cannot log-transform an expression matrix containing negative "
                "values. Negative values indicate the data is already log-scale "
                "or centred β€” pass input_scale='log'."
            )
        expression_df = np.log2(expression_df + 1)
        applied_transform = "log2(x+1)"
        run_warnings.append(
            f"Input looked linear (max={scale_info['max_value']:.1f} > "
            f"{LOG_SCALE_MAX_THRESHOLD:g}); applied a log2(x+1) transform before "
            "scoring. ULM/PROGENy assume roughly symmetric log-scale input β€” "
            "scoring linear TPM/CPM lets a few high-expression genes dominate. "
            "Pass input_scale='log' to suppress this if the data really is log-scale."
        )
    elif input_scale == "log" and detected == "linear":
        run_warnings.append(
            f"input_scale='log' was requested but the matrix looks linear "
            f"(max={scale_info['max_value']:.1f} > {LOG_SCALE_MAX_THRESHOLD:g}). "
            "No transform was applied, so activity scores may be distorted by "
            "high-expression genes. Verify the dataset's data_level."
        )

    # ── Load network ─────────────────────────────────────────────────────
    network_top: int | None = None
    if _network is not None:
        net = _network.copy()
    elif resource == "progeny":
        # ``top`` is pinned explicitly: decoupler's default has shifted across
        # releases, so an implicit call makes scores irreproducible on upgrade.
        network_top = progeny_top
        net = dc.op.progeny(organism=organism, top=progeny_top)
    elif resource == "collectri":
        net = dc.op.collectri(organism=organism)
    else:  # hallmark
        net = dc.op.hallmark(organism=organism)

    # ── Gene coverage ────────────────────────────────────────────────────
    target_col = _find_network_target_col(net)
    expr_genes = set(expression_df.columns.astype(str))

    if target_col:
        network_genes: set[str] = set(net[target_col].astype(str).unique())
        n_network = len(network_genes)
        n_matched = len(network_genes & expr_genes)
        coverage_pct: float | None = round(100 * n_matched / n_network, 1) if n_network > 0 else 0.0

        if coverage_pct is not None and coverage_pct < 20:
            run_warnings.append(
                f"Low gene coverage: {n_matched}/{n_network} network genes "
                f"({coverage_pct}%) found in expression matrix. "
                "Check that column names are HGNC gene symbols."
            )
        elif coverage_pct is not None and coverage_pct < 50:
            run_warnings.append(
                f"Moderate gene coverage: {n_matched}/{n_network} network genes "
                f"({coverage_pct}%) found in expression matrix."
            )
    else:
        n_network = len(net)
        n_matched = 0
        coverage_pct = None
        run_warnings.append(
            "Could not identify target gene column in network β€” gene coverage check skipped."
        )

    # ── Sample count ─────────────────────────────────────────────────────
    n_samples = len(expression_df)
    if n_samples < min_n:
        run_warnings.append(
            f"Expression matrix has {n_samples} sample(s), below min_n={min_n}. "
            "Activity estimates from very small cohorts should be interpreted "
            "with caution."
        )

    # ── Pre-flight: zero-overlap guard ───────────────────────────────────
    if target_col and n_matched == 0:
        raise ValueError(
            f"Zero gene overlap: none of the {n_network} {resource} network "
            f"genes were found in the expression columns. "
            "Ensure column names are HGNC gene symbols."
        )

    # ── Run scoring ──────────────────────────────────────────────────────
    # Wrap decoupler's AssertionError (too few overlapping targets per source)
    # into a ValueError with a more actionable message.
    try:
        if method == "ulm":
            acts, pvals = dc.mt.ulm(data=expression_df, net=net)
        elif method == "mlm":
            acts, pvals = dc.mt.mlm(data=expression_df, net=net)
        else:  # zscore
            acts, pvals = dc.mt.zscore(data=expression_df, net=net)
    except AssertionError as exc:
        raise ValueError(
            f"decoupleR could not score with resource='{resource}': {exc}. "
            f"Gene coverage: {n_matched}/{n_network} ({coverage_pct}%). "
            "Each source requires β‰₯5 overlapping target genes by default. "
            "Ensure expression columns are HGNC gene symbols and sufficient "
            "genes are covered."
        ) from exc

    run_warnings.append(
        f"Activity scores computed with method='{method}', "
        f"resource='{resource}' ({_RESOURCE_DESCRIPTIONS.get(resource, '')}), "
        f"organism='{organism}'. "
        f"Input scale detected='{detected}', transform applied='{applied_transform}'"
        + (f", PROGENy top={network_top}" if network_top is not None else "")
        + ". Do not interpret activity scores as log fold-changes."
    )

    return {
        "activity_df": acts,
        "pvalue_df": pvals,
        "n_samples": n_samples,
        "n_activities": int(acts.shape[1]),
        "n_network_genes": n_network,
        "n_matched_genes": n_matched,
        "coverage_pct": coverage_pct,
        "resource": resource,
        "organism": organism,
        "method": method,
        "input_scale_requested": input_scale,
        "input_scale_detected": detected,
        "applied_transform": applied_transform,
        "expression_max": scale_info["max_value"],
        "network_top": network_top,
        "warnings": run_warnings,
    }