""" Cross-dataset integration tools (ADR-0001, Layer 1). This MCP server exposes the cross-dataset result-combination paths: 1. decoupler_meta_analyze: *late integration* — combine >=2 per-dataset result files (same result_type + contrast) into one meta-analyzed table with per-feature cross-dataset heterogeneity (Cochran's Q / I^2). For INDEPENDENT cohorts (plan mode == "late"). 2. decoupler_normalization_concordance: *same-cohort sensitivity check* — compare >=2 results that are the SAME samples quantified/normalized different ways (sibling variants; plan mode == "concordance"). It does NOT combine — it reports descriptive agreement (Pearson/Spearman, sign-concordance, significant- call overlap), because meta-analyzing identical samples double-counts the cohort. Routed here by get_integration_plan's mode == "concordance". 3. decoupler_integrate_datasets: *early integration — DE* — POOL >=2 datasets into one matrix (feature intersection + a `batch` obs key) and run a single batch-aware DE (`dataset` as a covariate). Only for datasets the plan rules "early" (shared feature space + poolable data_level); it re-checks the plan and refuses/reroutes otherwise. (ADR-0001 T9, Mode A.) 4. decoupler_pool_cohorts: *early integration — SCORING* — POOL >=2 datasets and ComBat-correct (scanpy.pp.combat on the log-normalised matrix) into ONE pre-normalised matrix for PER-SAMPLE activity scoring (dataset_score_bulk_samples). The no-design-matrix counterpart to decoupler_integrate_datasets: scoring has no design matrix to carry a batch covariate, so the pooled matrix is batch-corrected before scoring. Same "early"-only plan gate. (ADR-0001 item 10, Mode A.) Design (ADR-0001 Mode B): - The *math* is generic over the result envelope and lives in src/workflows/meta_analysis.py (``combine_envelopes``); *strategy selection* is field-based in src/core/combine.py. This tool is the Layer-1 boundary: it reads the producer tools' native CSVs into the envelope, runs the engine, and writes the combined table. It never picks the combine math by tool identity. - Which dataset can be combined with which is decided upstream by ``dataset_get_integration_plan`` (mode early/late/refuse); on a "late" verdict the specialist runs each dataset's pipeline, then calls this tool. """ from __future__ import annotations from datetime import datetime from pathlib import Path from typing import Annotated, Literal, Optional import pandas as pd from fastmcp import FastMCP from src.core.combine import ( CANONICAL_RESULT_TYPES, ENVELOPE_FEATURE_FIELD, ENVELOPE_VALUE_FIELDS, ) from src.tools.rna._base import OUTPUT_DIR from src.workflows.concordance import concordance_metrics from src.workflows.meta_analysis import combine_envelopes # Mode-A early integration (T9 + item 10): the plan gate, the combined-AnnData # builder, the ComBat pre-correction (scoring path), and the batch-aware DE tool. # Module-level so tests can monkeypatch them. from src.datasets.registry import get_integration_plan from src.workflows.integration import batch_correct_for_scoring, build_combined_anndata from src.tools.rna.analysis import decoupler_differential_expression integration_mcp = FastMCP(name="integration") # Enrichment tools serialise a WIDE activity matrix (1 contrast row x N features); # the DE tool serialises a LONG table (feature rows x stat/pvalue/padj columns). # These are the only two native shapes produced by the four `@combinable` rna # tools — the result_type tells the reader which shape to expect. _ENRICHMENT_RESULT_TYPES = frozenset({ "tf_enrichment", "pathway_enrichment", "hallmark_enrichment", }) # Default feature-label column per result_type (matches the rna tools' combine # descriptors). Only used for messaging — the envelope feature column is always # normalised to ENVELOPE_FEATURE_FIELD. _DEFAULT_FEATURE_FIELD = { "de": "gene", "tf_enrichment": "TF", "pathway_enrichment": "Pathway", "hallmark_enrichment": "Gene_Set", } _REQUIRED_SPEC_KEYS = ("path", "result_type", "contrast") def _spec_to_envelope(spec: dict) -> pd.DataFrame: """Read one per-dataset result file into a Mode-B result envelope. ``spec`` keys: ``path`` (the result CSV), ``result_type`` (one of CANONICAL_RESULT_TYPES), ``contrast`` (the comparison label), optional ``dataset_id`` (defaults to the file stem) and optional ``padj_path`` (enrichment only — a companion p-adjusted CSV). Two native shapes are handled, chosen by ``result_type``: - enrichment (tf/pathway/hallmark): the activities CSV is WIDE — one contrast row x N feature columns. The single row is transposed to one row per feature with the activity under the envelope ``score`` column. A ``padj_path`` companion, if given, contributes the envelope ``padj`` column. - ``de``: the results CSV is already LONG — feature rows with envelope-named value columns (``stat``/``pvalue``/``padj``); those present are carried through. The shared tags (``result_type``/``contrast``/``dataset_id``) are stamped on every row so the caller's result-level compatibility check and the combined output can identify the source. """ path = Path(spec["path"]) if not path.exists(): raise FileNotFoundError(f"result file not found: {path}") result_type = spec["result_type"] contrast = spec["contrast"] dataset_id = spec.get("dataset_id") or path.stem raw = pd.read_csv(path, index_col=0) if raw.shape[0] == 0 or raw.shape[1] == 0: raise ValueError(f"result file is empty: {path}") if result_type in _ENRICHMENT_RESULT_TYPES: # Wide activities: take the (single) contrast row -> score per feature. scores = raw.iloc[0] env = pd.DataFrame({ ENVELOPE_FEATURE_FIELD: scores.index.astype(str), "score": pd.to_numeric(scores.to_numpy(), errors="coerce"), }) padj_path = spec.get("padj_path") if padj_path: padj_raw = pd.read_csv(padj_path, index_col=0) padj_row = padj_raw.iloc[0] padj_map = { str(k): pd.to_numeric(v, errors="coerce") for k, v in padj_row.items() } env["padj"] = env[ENVELOPE_FEATURE_FIELD].map(padj_map) else: # Long table: feature in the index, envelope value columns by name. env = raw.copy() env.index = env.index.astype(str) # reset_index() makes the former index the first column; name it `feature`. env = env.reset_index() env = env.rename(columns={env.columns[0]: ENVELOPE_FEATURE_FIELD}) present = [f for f in ENVELOPE_VALUE_FIELDS if f in env.columns] if not present: raise ValueError( f"result file {path} (result_type='{result_type}') has none of the " f"envelope value columns {ENVELOPE_VALUE_FIELDS}; columns present: " f"{list(raw.columns)}" ) env = env[[ENVELOPE_FEATURE_FIELD, *present]] env["result_type"] = result_type env["contrast"] = contrast env["dataset_id"] = dataset_id return env def _refusal(reason: str, **extra) -> dict: """Uniform refuse payload (mirrors the dataset tools' error-dict convention).""" return {"error": reason, "refused": True, **extra} @integration_mcp.tool def decoupler_meta_analyze( results: Annotated[ list[dict], "Two or more per-dataset result specs to meta-analyze (late integration). " "Each item is a dict: {'path': , 'result_type': one of " "'de'|'tf_enrichment'|'pathway_enrichment'|'hallmark_enrichment', " "'contrast': , 'dataset_id': , 'padj_path': }. For DE use " "the de_results_path; for enrichment use the activities CSV (output_path). " "ALL items MUST share the same result_type AND contrast.", ], strategy: Annotated[ Optional[str], "Optional combine strategy override: 'inverse_variance' | 'stouffer' | " "'fisher' | 'rank_aggregation'. Default None auto-selects the best strategy " "the available envelope fields support (effect-size > Stouffer > Fisher).", ] = None, out_prefix: Annotated[ Optional[str], "Output file prefix for the combined results CSV." ] = None, ) -> dict: """ Meta-analyze (late integration) >=2 per-dataset result tables into one. Use this for the "late" branch of a cross-dataset request: after dataset_get_integration_plan returns mode=="late", run each dataset's pipeline SEPARATELY (same analysis tool, same contrast/method on each), then call this tool with the per-dataset result files. It enforces the result-level compatibility check (all inputs share result_type AND contrast — a mismatch is refused), reads each result into the common envelope, combines them with a field-dispatched strategy (no pooling of raw expression), and reports per-feature cross-dataset heterogeneity (Cochran's Q and I^2). High I^2 (>50%) flags a feature whose effect is inconsistent across cohorts. Returns a summary dict with the chosen strategy, the combined results CSV path, the number of features and significant features, and the top combined rows. Returns an {"error": ..., "refused": True} dict (does not raise) when inputs are incompatible, so the agent can surface the reason to the user. """ # --- Validate arity + per-item schema --------------------------------- if not isinstance(results, list) or len(results) < 2: return _refusal( f"Meta-analysis needs at least 2 per-dataset results; got " f"{len(results) if isinstance(results, list) else 'a non-list'}." ) for i, spec in enumerate(results): if not isinstance(spec, dict): return _refusal(f"results[{i}] is not a dict: {spec!r}") missing = [k for k in _REQUIRED_SPEC_KEYS if not spec.get(k)] if missing: return _refusal(f"results[{i}] is missing required key(s) {missing}.") rt = spec["result_type"] if rt not in CANONICAL_RESULT_TYPES: return _refusal( f"results[{i}] has unknown result_type '{rt}'; expected one of " f"{sorted(CANONICAL_RESULT_TYPES)}." ) # --- Result-level compatibility: shared result_type AND contrast ------ result_types = {s["result_type"] for s in results} contrasts = {s["contrast"] for s in results} if len(result_types) > 1: return _refusal( "Cannot meta-analyze across different result types " f"{sorted(result_types)} — combine only like-with-like (e.g. all " "tf_enrichment). Run the SAME analysis tool on each dataset first.", result_types=sorted(result_types), ) if len(contrasts) > 1: return _refusal( f"Cannot meta-analyze across different contrasts {sorted(contrasts)} — " "every dataset must be analyzed with the same contrast before combining.", contrasts=sorted(contrasts), ) result_type = next(iter(result_types)) contrast = next(iter(contrasts)) # --- Read each result into the envelope ------------------------------- try: envelopes = [_spec_to_envelope(spec) for spec in results] except (FileNotFoundError, ValueError) as exc: return _refusal(f"Could not read a result into the envelope: {exc}") dataset_ids = [ spec.get("dataset_id") or Path(spec["path"]).stem for spec in results ] # --- Combine (field-based strategy dispatch) -------------------------- try: combined = combine_envelopes(envelopes, strategy=strategy) except Exception as exc: # NoCombineStrategyError, ValueError, ... return _refusal(f"Meta-analysis could not combine these results: {exc}") strategy_used = combined.attrs.get("strategy", strategy or "auto") out_prefix = out_prefix or f"meta_{result_type}_{datetime.now():%Y%m%d_%H%M%S}" out_path = OUTPUT_DIR / f"{out_prefix}_meta_analysis.csv" OUTPUT_DIR.mkdir(parents=True, exist_ok=True) combined.to_csv(out_path, index=False) n_features = int(len(combined)) n_sig = int((combined["padj"] < 0.05).sum()) if "padj" in combined else 0 return { "message": ( f"Meta-analyzed {len(results)} datasets " f"({', '.join(dataset_ids)}) for {result_type} / contrast '{contrast}' " f"using the '{strategy_used}' strategy. {n_features} features combined, " f"{n_sig} significant (padj < 0.05)." ), "mode": "late", "result_type": result_type, "contrast": contrast, "datasets_combined": dataset_ids, "n_datasets": len(results), "strategy": strategy_used, "available_fields": combined.attrs.get("available_fields", []), "n_features": n_features, "n_significant": n_sig, "combined_results_path": str(out_path.resolve()), "output_path": str(out_path.resolve()), "top_results": combined.head(10).round(4).to_dict(orient="records"), "heterogeneity_note": ( "Per-feature Cochran's Q and I^2 are in the combined CSV (columns Q, " "I2). I^2 > 50% flags a feature whose effect is inconsistent across " "cohorts; report it alongside the combined estimate." ), "artifacts": [ { "description": "Meta-analysis combined results (combined stat/score/p, padj, Q, I2)", "path": str(out_path.resolve()), } ], } @integration_mcp.tool def decoupler_normalization_concordance( results: Annotated[ list[dict], "Two or more SAME-COHORT variant result specs to compare (e.g. the TPM " "and TMM quantifications of one cohort). Each item is a dict: {'path': " ", 'result_type': one of 'de'|'tf_enrichment'|" "'pathway_enrichment'|'hallmark_enrichment', 'contrast':