Paper2Agent_decoupleRpy / src /core /top_table.py
Annie Voigt
fix(tools): select the contrast row positionally so top_table cannot fail the tool
d618f5b
Raw
History Blame Contribute Delete
6.21 kB
"""
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."
)