File size: 6,213 Bytes
fc3be13
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bc061b9
 
 
 
 
 
fc3be13
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bc061b9
 
 
fc3be13
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d618f5b
 
 
 
 
 
 
 
fc3be13
 
 
 
 
 
 
 
 
 
 
d618f5b
fc3be13
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""
Inline reporting tables for the tools that terminate an analysis chain.

Why this exists — measured on the dev Space 2026-08-06, not assumed. The
"reporting tail" (2 extra steps between the last computation and <solution>) is
NOT a context-window problem: on an 8-step run only 11 messages exist when the
tail starts, and `memory_window` is 15, so nothing has been truncated and the
results observation is still in full view. The agent re-reads anyway because the
tool result contains no numbers to quote — `decoupler_differential_expression`
returns counts and paths, and the enrichment tools return
`top_result_names` (names only) plus TWO separate CSVs (activities and padj)
that have to be joined before anything can be said about significance.

That also explains why the tail is consistently 2 steps rather than 1: the first
re-read produces an unusable view (a `.head()` of an alphabetically-ordered padj
file, or a sort by a padj column that is 0.0 for thousands of genes), so a
second step is needed to join, filter and sort. Both steps do real work. The fix
is to hand back the joined, sorted, significance-filtered rows directly.

This mirrors what `dataset_compare_activity_by_group` already does with its own
`top_table`; these helpers exist so the three enrichment tools and the DE tool
share one implementation rather than four hand-rolled copies.
"""

from __future__ import annotations

# Rows per direction. Sized against the executor's MAX_OUTPUT_CHARS (4000): at
# 15 per direction the printed tool result overran the cap and was truncated,
# silently dropping the tail of the dict. 10 per direction keeps every measured
# result inside the cap, and 20 rows is also what these queries ask for
# ("report the top 20 genes").
TOP_N = 10


def _num(value, dp: int = 4):
    """Round for prompt economy; return None rather than a NaN (not JSON-safe)."""
    try:
        f = float(value)
    except (TypeError, ValueError):
        return None
    if f != f:  # NaN
        return None
    return round(f, dp)


def _both_directions(frame, sort_col: str, n: int) -> list:
    """Top n rows by `sort_col` from each end, highest first, no duplicates."""
    ordered = frame.sort_values(sort_col, ascending=False)
    if len(ordered) <= 2 * n:
        return list(ordered.index)
    return list(ordered.index[:n]) + list(ordered.index[-n:])


def de_top_table(results_df, n: int = TOP_N) -> tuple[list[dict], bool]:
    """Top up- and down-regulated genes with their statistics.

    Ranked by `stat` rather than `padj`: on a strong contrast padj is 0.0 for
    thousands of genes, so sorting by it returns an arbitrary slice of a tie —
    which is exactly what sent one measured run back for a second re-read.

    Returns (rows, is_significant). When nothing clears padj < 0.05 the strongest
    rows are returned anyway with is_significant False, so a null result can
    still be reported with numbers instead of triggering a trip to the CSV.
    """
    # pvalue is deliberately omitted: padj is what gets reported, and on a strong
    # contrast both are 0.0 — it cost characters against the output cap for nothing.
    cols = [c for c in ("log2FoldChange", "stat", "padj") if c in results_df.columns]
    if "stat" not in results_df.columns or results_df.empty:
        return [], False

    sig = results_df[results_df["padj"] < 0.05] if "padj" in results_df.columns else results_df
    source, is_sig = (sig, True) if len(sig) else (results_df, False)

    rows = [
        {"gene": str(g), **{c: _num(source.loc[g, c]) for c in cols}}
        for g in _both_directions(source, "stat", n)
    ]
    return rows, is_sig


def activity_top_table(acts, padj, significant_features, n: int = TOP_N) -> tuple[list[dict], bool]:
    """Join an activity frame with its padj frame into rows the agent can quote.

    `acts` and `padj` are decoupleR's (contrasts x features) frames; the first
    row is the contrast. They are written to two separate CSVs, so without this
    join the agent has to read both files back and merge them itself.
    """
    if acts is None or len(acts) == 0 or acts.shape[1] == 0:
        return [], False

    sig = [f for f in significant_features if f in acts.columns]
    source, is_sig = (sig, True) if sig else (list(acts.columns), False)

    # Select the contrast row POSITIONALLY. Label-based .loc returns a DataFrame
    # rather than a Series when the contrast label is duplicated, and the
    # sort_values() below then raises — which fails the whole tool and sends the
    # agent into a retry loop. iloc cannot degrade that way.
    acts_row = acts.iloc[0]
    padj_row = padj.iloc[0] if padj is not None and len(padj) else None

    scores = acts_row[source]
    ordered = scores.sort_values(ascending=False)
    picked = (
        list(ordered.index)
        if len(ordered) <= 2 * n
        else list(ordered.index[:n]) + list(ordered.index[-n:])
    )

    rows = []
    for feature in picked:
        row = {"feature": str(feature), "score": _num(scores[feature])}
        try:
            row["padj"] = _num(padj_row[feature]) if padj_row is not None else None
        except (KeyError, TypeError, IndexError):
            row["padj"] = None
        rows.append(row)
    return rows, is_sig


def top_table_next_step(kind: str, n_significant: int, is_significant: bool, path_hint: str) -> str:
    """The `next_step` string that tells the agent it already has the numbers."""
    head = f"Found {n_significant} significant {kind} (padj < 0.05). "
    if is_significant:
        body = (
            "`top_table` holds the strongest significant rows in BOTH directions, "
            "already joined with padj and sorted — no merge needed. "
        )
    else:
        body = (
            "Nothing reached padj < 0.05; `top_table` holds the strongest rows "
            "anyway (top_table_is_significant = false) so the null result can be "
            "reported with numbers. "
        )
    return (
        head + body + "Those numbers are everything the solution needs — do NOT re-open "
        f"{path_hint} to write the report. Load that CSV only to plot, or if you "
        "genuinely need a row beyond the table."
    )